chore(*): drop everything that is not required for Tvix
Co-Authored-By: edef <edef@edef.eu> Co-Authored-By: Ryan Lahfa <raito@lix.systems> Change-Id: I9817214c3122e49d694c5e41818622a08d9dfe45
This commit is contained in:
parent
bd91cac1f3
commit
df4500ea2b
2905 changed files with 34 additions and 493328 deletions
3
web/atward/.gitignore
vendored
3
web/atward/.gitignore
vendored
|
|
@ -1,3 +0,0 @@
|
|||
result
|
||||
/target
|
||||
**/*.rs.bk
|
||||
881
web/atward/Cargo.lock
generated
881
web/atward/Cargo.lock
generated
|
|
@ -1,881 +0,0 @@
|
|||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 3
|
||||
|
||||
[[package]]
|
||||
name = "adler32"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234"
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2969dcb958b36655471fc61f7e416fa76033bdd4bfed0678d8fee1e2d07a1f0"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alloc-no-stdlib"
|
||||
version = "2.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
|
||||
|
||||
[[package]]
|
||||
name = "alloc-stdlib"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "android-tzdata"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0"
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ascii"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
|
||||
|
||||
[[package]]
|
||||
name = "atward"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"regex",
|
||||
"rouille",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "327762f6e5a765692301e5bb513e0d9fef63be86bbc14528052b1cd3e6f03e07"
|
||||
|
||||
[[package]]
|
||||
name = "brotli"
|
||||
version = "3.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "516074a47ef4bce09577a3b379392300159ce5b1ba2e501ff1c819950066100f"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
"brotli-decompressor",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli-decompressor"
|
||||
version = "2.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e2e4afe60d7dd600fdd3de8d0f08c2b7ec039712e3b6137ff98b7004e82de4f"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "buf_redux"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b953a6887648bb07a535631f2bc00fbdb2a2216f135552cb3f534ed136b9c07f"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"safemem",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f30e7476521f6f8af1a1c4c0b8cc94f0bee37d91763d0ca2665f299b6cd8aec"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.0.84"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0f8e7c90afad890484a21653d08b6e209ae34770fb5ee298f9c699fcc1e5c856"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f2c685bad3eb3d45a01354cedb7d5faa66194d1d58ba6e267a8de788f79db38"
|
||||
dependencies = [
|
||||
"android-tzdata",
|
||||
"iana-time-zone",
|
||||
"num-traits",
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chunked_transfer"
|
||||
version = "1.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cca491388666e04d7248af3f60f0c40cfb0991c72205595d7c396e3510207d1a"
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa"
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deflate"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c86f7e25f518f4b81808a2cf1c50996a61f5c2eb394b2393bd87f2a4780a432f"
|
||||
dependencies = [
|
||||
"adler32",
|
||||
"gzip-header",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0f32d04922c60427da6f9fef14d042d9edddef64cb9d4ce0d64d0685fbeb1fd3"
|
||||
dependencies = [
|
||||
"powerfmt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c18ee0ed65a5f1f81cac6b1d213b69c35fa47d4252ad41f1486dbd8226fe36e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5"
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4029edd3e734da6fe05b6cd7bd2960760a616bd2ddd0d59a0124746d6272af0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"redox_syscall 0.3.5",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a62bc1cf6f830c2ec14a513a9fb124d0a213a629668a4186f329db21fe045652"
|
||||
dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe9006bed769170c11f845cf00c7c1e9092aeb3f268e007c3e760ac68008070f"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gzip-header"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "95cc527b92e6029a62960ad99aa8a6660faa4555fe5f731aab13aa6a921795a2"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d77f7ec81a6d05a3abb01ab6eb7590f6083d08449fe5a1c8b1e620283546ccb7"
|
||||
|
||||
[[package]]
|
||||
name = "httparse"
|
||||
version = "1.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904"
|
||||
|
||||
[[package]]
|
||||
name = "httpdate"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone"
|
||||
version = "0.1.58"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8326b86b6cff230b97d0d312a6c40a60726df3332e721f72a1b035f451663b20"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"core-foundation-sys",
|
||||
"iana-time-zone-haiku",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone-haiku"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c"
|
||||
dependencies = [
|
||||
"unicode-bidi",
|
||||
"unicode-normalization",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af150ab688ff2122fcef229be89cb50dd66af9e01a4ff320cc137eecc9bacc38"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.65"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "54c0c35952f67de54bb584e9fd912b3023117cbafc0a77d8f3dee1fb5f572fe8"
|
||||
dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.150"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "89d92a4743f9a61002fae18374ed11e7973f530cb3a3255fb354818118b2203c"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.4.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "969488b55f8ac402214f3f5fd243ebb7206cf82de60d3172994707a4bcc2b829"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f665ee40bc4a3c5590afb1e9677db74a508659dfd71e126420da8274909a0167"
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "mime_guess"
|
||||
version = "2.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4192263c238a5f0d0c6bfd21f336a313a4ce1c450542449ca191bb657b4642ef"
|
||||
dependencies = [
|
||||
"mime",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multipart"
|
||||
version = "0.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00dec633863867f29cb39df64a397cdf4a6354708ddd7759f70c7fb51c5f9182"
|
||||
dependencies = [
|
||||
"buf_redux",
|
||||
"httparse",
|
||||
"log",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"quick-error",
|
||||
"rand",
|
||||
"safemem",
|
||||
"tempfile",
|
||||
"twoway",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39e3200413f237f41ab11ad6d161bc7239c84dcb631773ccd7de3dfe4b5c267c"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num_cpus"
|
||||
version = "1.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43"
|
||||
dependencies = [
|
||||
"hermit-abi",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num_threads"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2819ce041d2ee131036f4fc9d6ae7ae125a3a40e97ba64d04fe799ad9dabbb44"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d"
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94"
|
||||
|
||||
[[package]]
|
||||
name = "powerfmt"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "134c189feb4956b20f6f547d2cf727d4c0fe06722b20a0eec87ed445a97f92da"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "1.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.3.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "567664f262709473930a4bf9e51bf2ebf3348f2e748ccc50dea20646858f8f29"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "380b951a9c5e80ddfd6136919eef32310721aa4aacd4889a8d39124b026ab343"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5f804c7828047e88b2d32e2d7fe5a105da8ee3264f01902f796c8e067dc2483f"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f"
|
||||
|
||||
[[package]]
|
||||
name = "rouille"
|
||||
version = "3.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3716fbf57fc1084d7a706adf4e445298d123e4a44294c4e8213caf1b85fcc921"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"brotli",
|
||||
"chrono",
|
||||
"deflate",
|
||||
"filetime",
|
||||
"multipart",
|
||||
"percent-encoding",
|
||||
"rand",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"sha1_smol",
|
||||
"threadpool",
|
||||
"time",
|
||||
"tiny_http",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "0.38.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b426b0506e5d50a7d8dafcf2e81471400deb602392c7dd110815afb4eaf02a3"
|
||||
dependencies = [
|
||||
"bitflags 2.4.1",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ad4cc8da4ef723ed60bced201181d83791ad433213d8c24efffda1eec85d741"
|
||||
|
||||
[[package]]
|
||||
name = "safemem"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef703b7cb59335eae2eb93ceb664c0eb7ea6bf567079d843e09420219668e072"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.192"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bca2a08484b285dcb282d0f67b26cadc0df8b19f8c12502c13d966bf9482f001"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.192"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6c7207fbec9faa48073f3e3074cbe553af6ea512d7c21ba46e434e70ea9fbc1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.108"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d1c7e3eac408d115102c4c24ad393e0821bb3a5df4d506a80f85f7a742a526b"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1_smol"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae1a47186c03a32177042e55dbc5fd5aee900b8e0069a8d70fba96a9375cd012"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.39"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23e78b90f2fcf45d3e842032ce32e3f2d1545ba6636271dcbf24fa306d87be7a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ef1adac450ad7f4b3c28589471ade84f25f731a7a0fe30d71dfa9f60fd808e5"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"fastrand",
|
||||
"redox_syscall 0.4.1",
|
||||
"rustix",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "threadpool"
|
||||
version = "1.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa"
|
||||
dependencies = [
|
||||
"num_cpus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4a34ab300f2dee6e562c10a046fc05e358b29f9bf92277f30c3c8d82275f6f5"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"libc",
|
||||
"num_threads",
|
||||
"powerfmt",
|
||||
"serde",
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time-core"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3"
|
||||
|
||||
[[package]]
|
||||
name = "tiny_http"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82"
|
||||
dependencies = [
|
||||
"ascii",
|
||||
"chunked_transfer",
|
||||
"httpdate",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinyvec"
|
||||
version = "1.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50"
|
||||
dependencies = [
|
||||
"tinyvec_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinyvec_macros"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "twoway"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59b11b2b5241ba34be09c3cc85a36e56e48f9888862e19cedf23336d35316ed1"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7d2d4dafb69621809a81864c9c1b864479e1235c0dd4e199924b9742439ed89"
|
||||
dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-bidi"
|
||||
version = "0.3.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92888ba5573ff080736b3648696b70cafad7d250551175acbaa4e0385b3e1460"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-normalization"
|
||||
version = "0.1.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921"
|
||||
dependencies = [
|
||||
"tinyvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "143b538f18257fac9cad154828a57c6bf5157e1aa604d4816b5995bf6de87ae5"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"idna",
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.0+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.88"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7daec296f25a1bae309c0cd5c29c4b260e510e6d813c286b19eaadf409d40fce"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"wasm-bindgen-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-backend"
|
||||
version = "0.2.88"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e397f4664c0e4e428e8313a469aaa58310d302159845980fd23b0f22a847f217"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"log",
|
||||
"once_cell",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.88"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5961017b3b08ad5f3fe39f1e79877f8ee7c23c5e5fd5eb80de95abc41f1f16b2"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.88"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c5353b8dab669f5e10f5bd76df26a9360c748f054f862ff5f3f8aae0c7fb3907"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wasm-bindgen-backend",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.88"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d046c5d029ba91a1ed14da14dca44b68bf2f124cfbaf741c54151fdb3e0750b"
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.51.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1f8cf84f35d2db49a46868f947758c7a1138116f7fac3bc844f43ade1292e64"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.48.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
[package]
|
||||
name = "atward"
|
||||
version = "0.1.0"
|
||||
authors = ["Vincent Ambo <mail@tazj.in>"]
|
||||
edition = "2018"
|
||||
|
||||
[dependencies]
|
||||
regex = "1.5"
|
||||
rouille = "3.5"
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
//! Build script that can be used outside of Nix builds to inject the
|
||||
//! ATWARD_INDEX_HTML variable when building in development mode.
|
||||
//!
|
||||
//! Note that this script assumes that atward is in a checkout of the
|
||||
//! TVL depot.
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
static ATWARD_INDEX_HTML: &str = "ATWARD_INDEX_HTML";
|
||||
static ERROR_MESSAGE: &str = r#"Failed to build index page.
|
||||
|
||||
When building during development, atward expects to be in a checkout
|
||||
of the TVL depot. This is required to automatically build the index
|
||||
page that is needed at compile time.
|
||||
|
||||
As atward can not automatically detect the location of the page,
|
||||
you must set the `ATWARD_INDEX_HTML` environment variable to the
|
||||
right path.
|
||||
|
||||
The expected page is build using the files in //web/atward/indexHtml
|
||||
in the depot."#;
|
||||
|
||||
fn main() {
|
||||
// Do nothing if the variable is already set (e.g. via Nix)
|
||||
if let Ok(_) = std::env::var(ATWARD_INDEX_HTML) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise ask Nix to build it and inject the result.
|
||||
let output = Command::new("nix-build")
|
||||
.arg("-A")
|
||||
.arg("web.atward.indexHtml")
|
||||
// ... assuming atward is at //web/atward ...
|
||||
.arg("../..")
|
||||
.output()
|
||||
.expect(ERROR_MESSAGE);
|
||||
|
||||
if !output.status.success() {
|
||||
eprintln!(
|
||||
"{}\nNix output: {}",
|
||||
ERROR_MESSAGE,
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let out_path = String::from_utf8(output.stdout)
|
||||
.expect("Nix returned invalid output after building index page");
|
||||
|
||||
// Return an instruction to Cargo that will set the environment
|
||||
// variable during rustc calls.
|
||||
//
|
||||
// https://doc.rust-lang.org/cargo/reference/build-scripts.html#cargorustc-envvarvalue
|
||||
println!("cargo:rustc-env={}={}", ATWARD_INDEX_HTML, out_path.trim());
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
{ depot, ... }:
|
||||
|
||||
depot.third_party.naersk.buildPackage {
|
||||
src = ./.;
|
||||
override = x: {
|
||||
ATWARD_INDEX_HTML = depot.web.atward.indexHtml;
|
||||
};
|
||||
}
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
{ depot, ... }:
|
||||
|
||||
depot.web.tvl.template {
|
||||
title = "atward";
|
||||
content = ''
|
||||
atward
|
||||
======
|
||||
|
||||
----------
|
||||
|
||||
**atward** is [TVL's](https://tvl.fyi/) search
|
||||
service. It can be configured as a browser search engine for easy
|
||||
access to TVL bugs, code reviews, code paths and more.
|
||||
|
||||
### Setting up atward
|
||||
|
||||
To configure atward, add a search engine to your browser with the
|
||||
following search string: `https://at.tvl.fyi/?q=%s`
|
||||
Consider setting a shortcut, for example **t** or **tvl**.
|
||||
You can now quickly access TVL resources by typing something
|
||||
like <kbd>t b/42</kbd> in your URL bar to get to the bug with ID
|
||||
42.
|
||||
|
||||
|
||||
### Supported queries
|
||||
|
||||
The following query types are supported in atward:
|
||||
|
||||
* <kbd>b/42</kbd> - access bugs with ID 42
|
||||
* <kbd>cl/3087</kbd> - access changelist with ID 3087
|
||||
* <kbd>//web/atward</kbd> - open the **//web/atward** path in TVLs monorepo
|
||||
* <kbd>r/3002</kbd> - access revision 3002 in cgit
|
||||
|
||||
When given a short host name (e.g. <kbd>todo</kbd> or
|
||||
<kbd>cl</kbd>), atward will redirect to the appropriate `tvl.fyi`
|
||||
domain.
|
||||
|
||||
### Source code
|
||||
|
||||
atward's source code lives at
|
||||
[//web/atward](https://at.tvl.fyi/?q=%2F%2Fweb%2Fatward).
|
||||
'';
|
||||
|
||||
extraHead = ''
|
||||
<link rel="search" type="application/opensearchdescription+xml" title="TVL Search" href="https://at.tvl.fyi/opensearch.xml">
|
||||
'';
|
||||
}
|
||||
|
|
@ -1,160 +0,0 @@
|
|||
//! Atward implements TVL's redirection service, living at
|
||||
//! atward.tvl.fyi
|
||||
//!
|
||||
//! This service is designed to be added as a search engine to web
|
||||
//! browsers and attempts to send users to useful locations based on
|
||||
//! their search query (falling back to another search engine).
|
||||
use regex::Regex;
|
||||
use rouille::{Request, Response};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
/// A query handler supported by atward. It consists of a pattern on
|
||||
/// which to match and trigger the query, and a function to execute
|
||||
/// that returns the target URL.
|
||||
struct Handler {
|
||||
/// Regular expression on which to match the query string.
|
||||
pattern: Regex,
|
||||
|
||||
/// Function to construct the target URL. If the pattern matches,
|
||||
/// this is invoked with the captured matches and the entire URI.
|
||||
///
|
||||
/// Returning `None` causes atward to fall through to the next
|
||||
/// query (and eventually to the default search engine).
|
||||
target: for<'s> fn(&Query, regex::Captures<'s>) -> Option<String>,
|
||||
}
|
||||
|
||||
/// An Atward query supplied by a user.
|
||||
#[derive(Debug, PartialEq)]
|
||||
struct Query {
|
||||
/// Query string itself.
|
||||
query: String,
|
||||
}
|
||||
|
||||
impl Query {
|
||||
fn from_request(req: &Request) -> Option<Query> {
|
||||
match req.get_param("q") {
|
||||
Some(query) => Some(Query { query }),
|
||||
None => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl From<&str> for Query {
|
||||
fn from(query: &str) -> Query {
|
||||
Query {
|
||||
query: query.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a URL to a file (and, optionally, specific line) in cgit.
|
||||
fn cgit_url(path: &str) -> String {
|
||||
if path.ends_with(".md") {
|
||||
format!("https://code.tvl.fyi/about/{}", path)
|
||||
} else {
|
||||
format!("https://code.tvl.fyi/tree/{}", path)
|
||||
}
|
||||
}
|
||||
|
||||
/// Definition of all supported query handlers in atward.
|
||||
fn handlers() -> Vec<Handler> {
|
||||
vec![
|
||||
// Bug IDs (e.g. b/123)
|
||||
Handler {
|
||||
pattern: Regex::new("^b/(?P<bug>\\d+)$").unwrap(),
|
||||
target: |_, captures| Some(format!("https://b.tvl.fyi/{}", &captures["bug"])),
|
||||
},
|
||||
// Changelists (e.g. cl/42)
|
||||
Handler {
|
||||
pattern: Regex::new("^cl/(?P<cl>\\d+)$").unwrap(),
|
||||
target: |_, captures| Some(format!("https://cl.tvl.fyi/{}", &captures["cl"])),
|
||||
},
|
||||
// Non-parameterised short hostnames should redirect to $host.tvl.fyi
|
||||
Handler {
|
||||
pattern: Regex::new("^(?P<host>b|cl|cs|code|at|todo)$").unwrap(),
|
||||
target: |_, captures| Some(format!("https://{}.tvl.fyi/", &captures["host"])),
|
||||
},
|
||||
// Depot revisions (e.g. r/3002)
|
||||
Handler {
|
||||
pattern: Regex::new("^r/(?P<rev>\\d+)$").unwrap(),
|
||||
target: |_, captures| {
|
||||
Some(format!(
|
||||
"https://code.tvl.fyi/commit/?id=refs/r/{}",
|
||||
&captures["rev"]
|
||||
))
|
||||
},
|
||||
},
|
||||
// Depot paths (e.g. //web/atward or //ops/nixos/whitby/default.nix)
|
||||
// TODO(tazjin): Add support for specifying lines in a query parameter
|
||||
Handler {
|
||||
pattern: Regex::new("^//(?P<path>[a-zA-Z].*)?$").unwrap(),
|
||||
target: |_, captures| {
|
||||
// Pass an empty string if the path is missing, to
|
||||
// redirect to the depot root.
|
||||
let path = captures.name("path").map(|m| m.as_str()).unwrap_or("");
|
||||
Some(cgit_url(path))
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// Attempt to match against all known query types, and return the
|
||||
/// destination URL if one is found.
|
||||
fn dispatch(handlers: &[Handler], query: &Query) -> Option<String> {
|
||||
for handler in handlers {
|
||||
if let Some(captures) = handler.pattern.captures(&query.query) {
|
||||
if let Some(destination) = (handler.target)(query, captures) {
|
||||
return Some(destination);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Return the opensearch.xml file which is required for adding atward
|
||||
/// as a search engine in Firefox.
|
||||
fn opensearch() -> Response {
|
||||
Response::text(include_str!("opensearch.xml"))
|
||||
.with_unique_header("Content-Type", "application/opensearchdescription+xml")
|
||||
}
|
||||
|
||||
/// Render the atward index page which gives users some information
|
||||
/// about how to use the service.
|
||||
fn index() -> Response {
|
||||
Response::html(include_str!(env!("ATWARD_INDEX_HTML")))
|
||||
}
|
||||
|
||||
/// Render the fallback page which informs users that their query is
|
||||
/// unsupported.
|
||||
fn fallback() -> Response {
|
||||
Response::text("error for emphasis that i am angery and the query whimchst i angery atward")
|
||||
.with_status_code(404)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let queries = handlers();
|
||||
let address = std::env::var("ATWARD_LISTEN_ADDRESS")
|
||||
.expect("ATWARD_LISTEN_ADDRESS environment variable must be set");
|
||||
|
||||
rouille::start_server(&address, move |request| {
|
||||
rouille::log(&request, std::io::stderr(), || {
|
||||
if request.url() == "/opensearch.xml" {
|
||||
return opensearch();
|
||||
}
|
||||
|
||||
let query = match Query::from_request(&request) {
|
||||
Some(q) => q,
|
||||
None => return index(),
|
||||
};
|
||||
|
||||
match dispatch(&queries, &query) {
|
||||
None => fallback(),
|
||||
Some(destination) => Response::redirect_303(destination),
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">
|
||||
<ShortName>TVL</ShortName>
|
||||
<Description>The Virus Lounge Search</Description>
|
||||
<InputEncoding>UTF-8</InputEncoding>
|
||||
<Url type="text/html" template="https://at.tvl.fyi/">
|
||||
<Param name="q" value="{searchTerms}"/>
|
||||
</Url>
|
||||
</OpenSearchDescription>
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bug_query() {
|
||||
assert_eq!(
|
||||
dispatch(&handlers(), &"b/42".into()),
|
||||
Some("https://b.tvl.fyi/42".to_string())
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
dispatch(&handlers(), &"something only mentioning b/42".into()),
|
||||
None,
|
||||
);
|
||||
assert_eq!(dispatch(&handlers(), &"b/invalid".into()), None,);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cl_query() {
|
||||
assert_eq!(
|
||||
dispatch(&handlers(), &"cl/42".into()),
|
||||
Some("https://cl.tvl.fyi/42".to_string())
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
dispatch(&handlers(), &"something only mentioning cl/42".into()),
|
||||
None,
|
||||
);
|
||||
assert_eq!(dispatch(&handlers(), &"cl/invalid".into()), None,);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn depot_path_cgit_query() {
|
||||
assert_eq!(
|
||||
dispatch(&handlers(), &"//web/atward/default.nix".into()),
|
||||
Some("https://code.tvl.fyi/tree/web/atward/default.nix".to_string()),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
dispatch(&handlers(), &"//nix/readTree/README.md".into()),
|
||||
Some("https://code.tvl.fyi/about/nix/readTree/README.md".to_string()),
|
||||
);
|
||||
|
||||
assert_eq!(dispatch(&handlers(), &"/not/a/depot/path".into()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn depot_root_cgit_query() {
|
||||
assert_eq!(
|
||||
dispatch(
|
||||
&handlers(),
|
||||
&Query {
|
||||
query: "//".to_string(),
|
||||
}
|
||||
),
|
||||
Some("https://code.tvl.fyi/tree/".to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_host_queries() {
|
||||
assert_eq!(
|
||||
dispatch(&handlers(), &"cl".into()),
|
||||
Some("https://cl.tvl.fyi/".to_string()),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
dispatch(&handlers(), &"b".into()),
|
||||
Some("https://b.tvl.fyi/".to_string()),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
dispatch(&handlers(), &"todo".into()),
|
||||
Some("https://todo.tvl.fyi/".to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_to_query() {
|
||||
assert_eq!(
|
||||
Query::from_request(&Request::fake_http("GET", "/?q=b%2F42", vec![], vec![]))
|
||||
.expect("request should parse to a query"),
|
||||
Query {
|
||||
query: "b/42".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
Query::from_request(&Request::fake_http("GET", "/", vec![], vec![])),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn depot_revision_query() {
|
||||
assert_eq!(
|
||||
dispatch(&handlers(), &"r/3002".into()),
|
||||
Some("https://code.tvl.fyi/commit/?id=refs/r/3002".to_string())
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
dispatch(&handlers(), &"something only mentioning r/3002".into()),
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(dispatch(&handlers(), &"r/invalid".into()), None,);
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
sterni
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
# //web/bubblegum
|
||||
|
||||
`bubblegum` is a CGI programming library for the Nix expression language.
|
||||
It provides a few helpers to make writing CGI scripts which are executable
|
||||
using [//nix/nint](../../nix/nint/README.md) convenient.
|
||||
|
||||
An example nix.cgi script looks like this (don't worry about the shebang
|
||||
too much, you can use `web.bubblegum.writeCGI` to set this up without
|
||||
thinking twice):
|
||||
|
||||
```nix
|
||||
#!/usr/bin/env nint --arg depot '(import /path/to/depot {})'
|
||||
{ depot, ... }:
|
||||
|
||||
let
|
||||
inherit (depot.web.bubblegum)
|
||||
respond
|
||||
;
|
||||
in
|
||||
|
||||
respond "OK" {
|
||||
"Content-type" = "text/html";
|
||||
# further headers…
|
||||
} ''
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>hello world</title>
|
||||
</head>
|
||||
<body>
|
||||
hello world!
|
||||
</body>
|
||||
</html>
|
||||
''
|
||||
```
|
||||
|
||||
As you can see, the core component of `bubblegum` is the `respond`
|
||||
function which takes three arguments:
|
||||
|
||||
* The response status as the textual representation which is also
|
||||
returned to the client in the HTTP protocol, e. g. `"OK"`,
|
||||
`"Not Found"`, `"Bad Request"`, …
|
||||
|
||||
* An attribute set mapping header names to header values to be sent.
|
||||
|
||||
* The response body as a string.
|
||||
|
||||
Additionally it exposes a few helpers for working with the CGI
|
||||
environment like `pathInfo` which is a wrapper around
|
||||
`builtins.getEnv "PATH_INFO"`. The documentation for all exposed
|
||||
helpers is inlined in [default.nix](./default.nix) (you should be
|
||||
able to use `nixdoc` to render it).
|
||||
|
||||
For deployment purposes it is recommended to use `writeCGI` which
|
||||
takes a nix CGI script in the form of a derivation, path or string
|
||||
and builds an executable nix CGI script which has the correct shebang
|
||||
set and is automatically passed a version of depot from the nix store,
|
||||
so the script has access to the `bubblegum` library.
|
||||
|
||||
For example nix CGI scripts and a working deployment using `thttpd`
|
||||
see the [examples directory](./examples). You can also start a local
|
||||
server running the examples like this:
|
||||
|
||||
```
|
||||
$ nix-build -A web.bubblegum.examples && ./result
|
||||
# navigate to http://localhost:9000
|
||||
```
|
||||
|
|
@ -1,273 +0,0 @@
|
|||
{ depot, lib, pkgs, ... }:
|
||||
|
||||
let
|
||||
|
||||
inherit (depot.nix)
|
||||
runExecline
|
||||
getBins
|
||||
utils
|
||||
sparseTree
|
||||
nint
|
||||
;
|
||||
|
||||
minimalDepot = sparseTree {
|
||||
root = depot.path.origSrc;
|
||||
name = "minimal-depot";
|
||||
|
||||
paths = [
|
||||
# general depot things
|
||||
"default.nix"
|
||||
"nix/readTree"
|
||||
# nixpkgs for lib and packages
|
||||
"third_party/nixpkgs"
|
||||
"third_party/overlays"
|
||||
"third_party/sources"
|
||||
# bubblegum and its dependencies
|
||||
"web/bubblegum"
|
||||
"nix/runExecline"
|
||||
"nix/utils"
|
||||
"nix/sparseTree"
|
||||
# tvix docs for svg demo
|
||||
"tvix/docs"
|
||||
# for blog.nix
|
||||
"users/sterni/nix"
|
||||
];
|
||||
};
|
||||
|
||||
statusCodes = {
|
||||
# 1xx
|
||||
"Continue" = 100;
|
||||
"Switching Protocols" = 101;
|
||||
"Processing" = 102;
|
||||
"Early Hints" = 103;
|
||||
# 2xx
|
||||
"OK" = 200;
|
||||
"Created" = 201;
|
||||
"Accepted" = 202;
|
||||
"Non-Authoritative Information" = 203;
|
||||
"No Content" = 204;
|
||||
"Reset Content" = 205;
|
||||
"Partial Content" = 206;
|
||||
"Multi Status" = 207;
|
||||
"Already Reported" = 208;
|
||||
"IM Used" = 226;
|
||||
# 3xx
|
||||
"Multiple Choices" = 300;
|
||||
"Moved Permanently" = 301;
|
||||
"Found" = 302;
|
||||
"See Other" = 303;
|
||||
"Not Modified" = 304;
|
||||
"Use Proxy" = 305;
|
||||
"Switch Proxy" = 306;
|
||||
"Temporary Redirect" = 307;
|
||||
"Permanent Redirect" = 308;
|
||||
# 4xx
|
||||
"Bad Request" = 400;
|
||||
"Unauthorized" = 401;
|
||||
"Payment Required" = 402;
|
||||
"Forbidden" = 403;
|
||||
"Not Found" = 404;
|
||||
"Method Not Allowed" = 405;
|
||||
"Not Acceptable" = 406;
|
||||
"Proxy Authentication Required" = 407;
|
||||
"Request Timeout" = 408;
|
||||
"Conflict" = 409;
|
||||
"Gone" = 410;
|
||||
"Length Required" = 411;
|
||||
"Precondition Failed" = 412;
|
||||
"Payload Too Large" = 413;
|
||||
"URI Too Long" = 414;
|
||||
"Unsupported Media Type" = 415;
|
||||
"Range Not Satisfiable" = 416;
|
||||
"Expectation Failed" = 417;
|
||||
"I'm a teapot" = 418;
|
||||
"Misdirected Request" = 421;
|
||||
"Unprocessable Entity" = 422;
|
||||
"Locked" = 423;
|
||||
"Failed Dependency" = 424;
|
||||
"Too Early" = 425;
|
||||
"Upgrade Required" = 426;
|
||||
"Precondition Required" = 428;
|
||||
"Too Many Requests" = 429;
|
||||
"Request Header Fields Too Large" = 431;
|
||||
"Unavailable For Legal Reasons" = 451;
|
||||
# 5xx
|
||||
"Internal Server Error" = 500;
|
||||
"Not Implemented" = 501;
|
||||
"Bad Gateway" = 502;
|
||||
"Service Unavailable" = 503;
|
||||
"Gateway Timeout" = 504;
|
||||
"HTTP Version Not Supported" = 505;
|
||||
"Variant Also Negotiates" = 506;
|
||||
"Insufficient Storage" = 507;
|
||||
"Loop Detected" = 508;
|
||||
"Not Extended" = 510;
|
||||
"Network Authentication Required" = 511;
|
||||
};
|
||||
|
||||
/* Generate a CGI response. Takes three arguments:
|
||||
|
||||
1. Status of the response as a string which is
|
||||
the descriptive name in the protocol, e. g.
|
||||
`"OK"`, `"Not Found"` etc.
|
||||
2. Attribute set describing extra headers to
|
||||
send, keys and values should both be strings.
|
||||
3. Response content as a string.
|
||||
|
||||
See the [README](./README.md) for an example.
|
||||
|
||||
Type: either int string -> attrs string -> string -> string
|
||||
*/
|
||||
respond =
|
||||
# response status as an integer (status code) or its
|
||||
# textual representation in the HTTP protocol.
|
||||
# See `statusCodes` for a list of valid options.
|
||||
statusArg:
|
||||
# headers as an attribute set of strings
|
||||
headers:
|
||||
# response body as a string
|
||||
bodyArg:
|
||||
let
|
||||
status =
|
||||
if builtins.isInt statusArg
|
||||
then {
|
||||
code = statusArg;
|
||||
line = lib.findFirst
|
||||
(line: statusCodes."${line}" == statusArg)
|
||||
null
|
||||
(builtins.attrNames statusCodes);
|
||||
} else if builtins.isString statusArg then {
|
||||
code = statusCodes."${statusArg}" or null;
|
||||
line = statusArg;
|
||||
} else {
|
||||
code = null;
|
||||
line = null;
|
||||
};
|
||||
renderedHeaders = lib.concatStrings
|
||||
(lib.mapAttrsToList (n: v: "${n}: ${toString v}\r\n") headers);
|
||||
internalError = msg: respond 500
|
||||
{
|
||||
Content-type = "text/plain";
|
||||
} "bubblegum error: ${msg}";
|
||||
body = builtins.tryEval bodyArg;
|
||||
in
|
||||
if status.code == null || status.line == null
|
||||
then internalError "Invalid status ${lib.generators.toPretty {} statusArg}."
|
||||
else if !body.success
|
||||
then internalError "Unknown evaluation error in user code"
|
||||
else
|
||||
lib.concatStrings [
|
||||
"Status: ${toString status.code} ${status.line}\r\n"
|
||||
renderedHeaders
|
||||
"\r\n"
|
||||
body.value
|
||||
];
|
||||
|
||||
/* Returns the value of the `SCRIPT_NAME` environment
|
||||
variable used by CGI.
|
||||
*/
|
||||
scriptName = builtins.getEnv "SCRIPT_NAME";
|
||||
|
||||
/* Returns the value of the `PATH_INFO` environment
|
||||
variable used by CGI. All cases that could be
|
||||
considered as the CGI script's root (i. e.
|
||||
`PATH_INFO` is empty or `/`) is mapped to `"/"`
|
||||
for convenience.
|
||||
*/
|
||||
pathInfo =
|
||||
let
|
||||
p = builtins.getEnv "PATH_INFO";
|
||||
in
|
||||
if builtins.stringLength p == 0
|
||||
then "/"
|
||||
else p;
|
||||
|
||||
/* Helper function which converts a path from the
|
||||
root of the CGI script (i. e. something which
|
||||
could be the content of `PATH_INFO`) to an
|
||||
absolute path from the web root by also
|
||||
utilizing `scriptName`.
|
||||
|
||||
Type: string -> string
|
||||
*/
|
||||
absolutePath = path:
|
||||
if builtins.substring 0 1 path == "/"
|
||||
then "${scriptName}${path}"
|
||||
else "${scriptName}/${path}";
|
||||
|
||||
bins = getBins pkgs.coreutils [ "env" "tee" "cat" "printf" "chmod" ]
|
||||
// getBins nint [ "nint" ];
|
||||
|
||||
/* Type: args -> either path derivation string -> derivation
|
||||
*/
|
||||
writeCGI =
|
||||
{
|
||||
# if given sets the `PATH` to search for `nix-instantiate`
|
||||
# Useful when using for example thttpd which unsets `PATH`
|
||||
# in the CGI environment.
|
||||
binPath ? ""
|
||||
# name of the resulting derivation. Defaults to `baseNameOf`
|
||||
# the input path or name of the input derivation.
|
||||
# Must be given if the input is a string.
|
||||
, name ? null
|
||||
, ...
|
||||
}@args:
|
||||
input:
|
||||
let
|
||||
drvName =
|
||||
if builtins.isString input || args ? name
|
||||
then args.name
|
||||
else utils.storePathName input;
|
||||
script =
|
||||
if builtins.isPath input || lib.isDerivation input
|
||||
then input
|
||||
else if builtins.isString input
|
||||
then pkgs.writeText "${drvName}-source" input
|
||||
else builtins.throw "Unsupported input: ${lib.generators.toPretty {} input}";
|
||||
shebang = lib.concatStringsSep " " ([
|
||||
"#!${bins.env}"
|
||||
# use the slightly cursed /usr/bin/env -S which allows us
|
||||
# to pass any number of arguments to our interpreter
|
||||
# instead of maximum one using plain shebang which considers
|
||||
# everything after the first space as the second argument.
|
||||
"-S"
|
||||
] ++ lib.optionals (builtins.stringLength binPath > 0) [
|
||||
"PATH=${binPath}"
|
||||
] ++ [
|
||||
"${bins.nint}"
|
||||
# always pass depot so scripts can use this library
|
||||
"--arg depot '(import ${minimalDepot} {})'"
|
||||
]);
|
||||
in
|
||||
runExecline.local drvName { } [
|
||||
"importas"
|
||||
"out"
|
||||
"out"
|
||||
"pipeline"
|
||||
[
|
||||
"foreground"
|
||||
[
|
||||
"if"
|
||||
[ bins.printf "%s\n" shebang ]
|
||||
]
|
||||
"if"
|
||||
[ bins.cat script ]
|
||||
]
|
||||
"if"
|
||||
[ bins.tee "$out" ]
|
||||
"if"
|
||||
[ bins.chmod "+x" "$out" ]
|
||||
"exit"
|
||||
"0"
|
||||
];
|
||||
|
||||
in
|
||||
{
|
||||
inherit
|
||||
respond
|
||||
pathInfo
|
||||
scriptName
|
||||
absolutePath
|
||||
writeCGI
|
||||
;
|
||||
}
|
||||
|
|
@ -1,143 +0,0 @@
|
|||
{ depot, ... }:
|
||||
|
||||
let
|
||||
inherit (depot.third_party.nixpkgs)
|
||||
lib
|
||||
;
|
||||
|
||||
inherit (depot.users.sterni.nix)
|
||||
url
|
||||
fun
|
||||
string
|
||||
;
|
||||
|
||||
inherit (depot.web.bubblegum)
|
||||
pathInfo
|
||||
scriptName
|
||||
respond
|
||||
absolutePath
|
||||
;
|
||||
|
||||
# substituted using substituteAll in default.nix
|
||||
blogdir = "@blogdir@";
|
||||
# blogdir = toString ./posts; # for local testing
|
||||
|
||||
parseDate = post:
|
||||
let
|
||||
matched = builtins.match "/?([0-9]+)-([0-9]+)-([0-9]+)-.+" post;
|
||||
in
|
||||
if matched == null
|
||||
then [ 0 0 0 ]
|
||||
else builtins.map builtins.fromJSON matched;
|
||||
|
||||
parseTitle = post:
|
||||
let
|
||||
matched = builtins.match "/?[0-9]+-[0-9]+-[0-9]+-(.+).html" post;
|
||||
in
|
||||
if matched == null
|
||||
then "no title"
|
||||
else builtins.head matched;
|
||||
|
||||
dateAtLeast = a: b:
|
||||
builtins.all fun.id
|
||||
(lib.zipListsWith (partA: partB: partA >= partB) a b);
|
||||
|
||||
byPostDate = a: b:
|
||||
dateAtLeast (parseDate a) (parseDate b);
|
||||
|
||||
posts = builtins.sort byPostDate
|
||||
(builtins.attrNames
|
||||
(lib.filterAttrs (_: v: v == "regular")
|
||||
(builtins.readDir blogdir)));
|
||||
|
||||
generic = { title, inner, ... }: ''
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>${title}</title>
|
||||
<style>a:link, a:visited { color: blue; }</style>
|
||||
</head>
|
||||
<body>
|
||||
${inner}
|
||||
</body>
|
||||
</html>
|
||||
'';
|
||||
|
||||
index = posts: ''
|
||||
<main>
|
||||
<h1>blog posts</h1>
|
||||
<ul>
|
||||
'' + lib.concatMapStrings
|
||||
(post: ''
|
||||
<li>
|
||||
<a href="${absolutePath (url.encode {} post)}">${parseTitle post}</a>
|
||||
</li>
|
||||
'')
|
||||
posts + ''
|
||||
</ul>
|
||||
</main>
|
||||
'';
|
||||
|
||||
formatDate =
|
||||
let
|
||||
# Assume we never deal with years < 1000
|
||||
formatDigit = d: string.fit
|
||||
{
|
||||
char = "0";
|
||||
width = 2;
|
||||
}
|
||||
(toString d);
|
||||
in
|
||||
lib.concatMapStringsSep "-" formatDigit;
|
||||
|
||||
post = title: post: ''
|
||||
<main>
|
||||
<h1>${title}</h1>
|
||||
<div id="content">
|
||||
${builtins.readFile (blogdir + "/" + post)}
|
||||
</div>
|
||||
</main>
|
||||
<footer>
|
||||
<p>Posted on ${formatDate (parseDate post)}</p>
|
||||
<nav><a href="${scriptName}">index</a></nav>
|
||||
</footer>
|
||||
'';
|
||||
|
||||
validatePathInfo = pathInfo:
|
||||
let
|
||||
chars = string.toChars pathInfo;
|
||||
in
|
||||
builtins.length chars > 1
|
||||
&& !(builtins.elem "/" (builtins.tail chars));
|
||||
|
||||
response =
|
||||
if pathInfo == "/"
|
||||
then {
|
||||
title = "blog";
|
||||
status = 200;
|
||||
inner = index posts;
|
||||
}
|
||||
else if !(validatePathInfo pathInfo)
|
||||
then {
|
||||
title = "Bad Request";
|
||||
status = 400;
|
||||
inner = "No slashes in post names 😡";
|
||||
}
|
||||
# CGI should already url.decode for us
|
||||
else if builtins.pathExists (blogdir + "/" + pathInfo)
|
||||
then rec {
|
||||
title = parseTitle pathInfo;
|
||||
status = 200;
|
||||
inner = post title pathInfo;
|
||||
} else {
|
||||
title = "Not Found";
|
||||
status = 404;
|
||||
inner = "<h1>404 — not found</h1>";
|
||||
};
|
||||
in
|
||||
respond response.status
|
||||
{
|
||||
"Content-type" = "text/html";
|
||||
}
|
||||
(generic response)
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
{ depot, pkgs, lib, ... }:
|
||||
|
||||
let
|
||||
|
||||
scripts = [
|
||||
./hello.nix
|
||||
(substituteAll {
|
||||
src = ./blog.nix;
|
||||
# by making this a plain string this
|
||||
# can be something outside the nix store!
|
||||
blogdir = ./posts;
|
||||
})
|
||||
];
|
||||
|
||||
inherit (depot.nix)
|
||||
writeExecline
|
||||
runExecline
|
||||
getBins
|
||||
;
|
||||
|
||||
inherit (depot.web.bubblegum)
|
||||
writeCGI
|
||||
;
|
||||
|
||||
inherit (pkgs)
|
||||
runCommandLocal
|
||||
substituteAll
|
||||
;
|
||||
|
||||
bins = (getBins pkgs.thttpd [ "thttpd" ])
|
||||
// (getBins pkgs.coreutils [ "printf" "cp" "mkdir" ]);
|
||||
|
||||
webRoot =
|
||||
let
|
||||
copyScripts = lib.concatMap
|
||||
(path:
|
||||
let
|
||||
cgi = writeCGI
|
||||
{
|
||||
# assume we are on NixOS since thttpd doesn't set PATH.
|
||||
# using third_party.nix is tricky because not everyone
|
||||
# has a tvix daemon running.
|
||||
binPath = "/run/current-system/sw/bin";
|
||||
}
|
||||
path;
|
||||
in
|
||||
[
|
||||
"if"
|
||||
[ bins.cp cgi "\${out}/${cgi.name}" ]
|
||||
])
|
||||
scripts;
|
||||
in
|
||||
runExecline.local "webroot" { } ([
|
||||
"importas"
|
||||
"out"
|
||||
"out"
|
||||
"if"
|
||||
[ bins.mkdir "-p" "$out" ]
|
||||
] ++ copyScripts);
|
||||
|
||||
port = 9000;
|
||||
|
||||
in
|
||||
writeExecline "serve-examples" { } [
|
||||
"foreground"
|
||||
[
|
||||
bins.printf
|
||||
"%s\n"
|
||||
"Running on http://localhost:${toString port}"
|
||||
]
|
||||
"${bins.thttpd}"
|
||||
"-D"
|
||||
"-p"
|
||||
(toString port)
|
||||
"-l"
|
||||
"/dev/stderr"
|
||||
"-c"
|
||||
"*.nix"
|
||||
"-d"
|
||||
webRoot
|
||||
]
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
{ depot, ... }:
|
||||
|
||||
let
|
||||
inherit (depot.third_party.nixpkgs)
|
||||
lib
|
||||
;
|
||||
|
||||
inherit (depot.web.bubblegum)
|
||||
pathInfo
|
||||
respond
|
||||
absolutePath
|
||||
;
|
||||
|
||||
routes = {
|
||||
"/" = {
|
||||
status = "OK";
|
||||
title = "index";
|
||||
content = ''
|
||||
Hello World!
|
||||
'';
|
||||
};
|
||||
"/clock" = {
|
||||
status = "OK";
|
||||
title = "clock";
|
||||
content = ''
|
||||
It is ${toString builtins.currentTime}s since 1970-01-01 00:00 UTC.
|
||||
'';
|
||||
};
|
||||
"/coffee" = {
|
||||
status = "I'm a teapot";
|
||||
title = "coffee";
|
||||
content = ''
|
||||
No coffee, I'm afraid
|
||||
'';
|
||||
};
|
||||
"/type-error" = {
|
||||
status = 666;
|
||||
title = "bad usage";
|
||||
content = ''
|
||||
Never gonna see this.
|
||||
'';
|
||||
};
|
||||
"/eval-error" = {
|
||||
status = "OK";
|
||||
title = "evaluation error";
|
||||
content = builtins.throw "lol";
|
||||
};
|
||||
};
|
||||
|
||||
notFound = {
|
||||
status = "Not Found";
|
||||
title = "404";
|
||||
content = ''
|
||||
This page doesn't exist.
|
||||
'';
|
||||
};
|
||||
|
||||
navigation =
|
||||
lib.concatStrings (lib.mapAttrsToList
|
||||
(p: v: "<li><a href=\"${absolutePath p}\">${v.title}</a></li>")
|
||||
routes);
|
||||
|
||||
template = { title, content, ... }: ''
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>${title}</title>
|
||||
<style>a:link, a:visited { color: blue; }</style>
|
||||
</head>
|
||||
<body>
|
||||
<hgroup>
|
||||
<h1><code>//web/bubblegum</code></h1>
|
||||
<h2>example app</h2>
|
||||
</hgroup>
|
||||
<header>
|
||||
<nav>
|
||||
<ul>${navigation}</ul>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<p>${content}</p>
|
||||
</main>
|
||||
</body>
|
||||
'';
|
||||
|
||||
response = routes."${pathInfo}" or notFound;
|
||||
|
||||
in
|
||||
respond response.status
|
||||
{
|
||||
"Content-type" = "text/html";
|
||||
}
|
||||
(template response)
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
<p>
|
||||
This is it, the peak of cursed.
|
||||
</p>
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
<p>
|
||||
<ul>
|
||||
<li>✅ sorting</li>
|
||||
<li>✅ url encoding (admire the spaces!)</li>
|
||||
<li>✅ classic Nix regex based parsing</li>
|
||||
</ul>
|
||||
</p>
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
# Wrapper for running cgit through thttpd with TVL-specific
|
||||
# configuration.
|
||||
#
|
||||
# In practice this is only used for //ops/modules/cgit, but exposing
|
||||
# it here makes it easy to experiment with cgit locally.
|
||||
{ depot, lib, pkgs, ... }:
|
||||
|
||||
let
|
||||
cgitConfig = repo: pkgs.writeText "cgitrc" ''
|
||||
# Global configuration
|
||||
virtual-root=/
|
||||
enable-http-clone=0
|
||||
readme=:README.md
|
||||
about-filter=${depot.tools.cheddar.about-filter}/bin/cheddar-about
|
||||
source-filter=${depot.tools.cheddar}/bin/cheddar
|
||||
enable-log-filecount=1
|
||||
enable-log-linecount=1
|
||||
enable-follow-links=0
|
||||
enable-blame=1
|
||||
mimetype-file=${pkgs.mime-types}/etc/mime.types
|
||||
logo=https://static.tvl.fyi/${depot.web.static.drvHash}/logo-animated.svg
|
||||
|
||||
# Repository configuration
|
||||
repo.url=depot
|
||||
repo.path=${repo}
|
||||
repo.desc=monorepo for the virus lounge
|
||||
repo.owner=The Virus Lounge
|
||||
repo.clone-url=https://code.tvl.fyi/depot.git
|
||||
'';
|
||||
|
||||
thttpdConfig = port: pkgs.writeText "thttpd.conf" ''
|
||||
port=${toString port}
|
||||
dir=${depot.third_party.cgit}/cgit
|
||||
nochroot
|
||||
novhost
|
||||
cgipat=**.cgi
|
||||
'';
|
||||
|
||||
# Patched version of thttpd that serves cgit.cgi as the index and
|
||||
# sets the environment variable for pointing cgit at the correct
|
||||
# configuration.
|
||||
#
|
||||
# Things are done this way because recompilation of thttpd is much
|
||||
# faster than cgit.
|
||||
thttpdConfigPatch = repo: pkgs.writeText "thttpd_cgit_conf.patch" ''
|
||||
diff --git a/libhttpd.c b/libhttpd.c
|
||||
index c6b1622..eef4b73 100644
|
||||
--- a/libhttpd.c
|
||||
+++ b/libhttpd.c
|
||||
@@ -3055,4 +3055,6 @@ make_envp( httpd_conn* hc )
|
||||
|
||||
envn = 0;
|
||||
+ // force cgit to load the correct configuration
|
||||
+ envp[envn++] = "CGIT_CONFIG=${cgitConfig repo}";
|
||||
envp[envn++] = build_env( "PATH=%s", CGI_PATH );
|
||||
#ifdef CGI_LD_LIBRARY_PATH
|
||||
'';
|
||||
|
||||
thttpdCgit = repo: pkgs.thttpd.overrideAttrs (old: {
|
||||
patches = [
|
||||
./thttpd_cgi_idx.patch
|
||||
(thttpdConfigPatch repo)
|
||||
];
|
||||
});
|
||||
|
||||
in
|
||||
lib.makeOverridable
|
||||
({ port ? 2448
|
||||
, repo ? "/var/lib/gerrit/git/depot.git/"
|
||||
}: pkgs.writeShellScript "cgit-launch" ''
|
||||
exec ${thttpdCgit repo}/bin/thttpd -D -C ${thttpdConfig port}
|
||||
'')
|
||||
{ }
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
diff --git a/config.h b/config.h
|
||||
index 65ab1e3..cde470f 100644
|
||||
--- a/config.h
|
||||
+++ b/config.h
|
||||
@@ -327,7 +327,7 @@
|
||||
/* CONFIGURE: A list of index filenames to check. The files are searched
|
||||
** for in this order.
|
||||
*/
|
||||
-#define INDEX_NAMES "index.html", "index.htm", "index.xhtml", "index.xht", "Default.htm", "index.cgi"
|
||||
+#define INDEX_NAMES "cgit.cgi"
|
||||
|
||||
/* CONFIGURE: If this is defined then thttpd will automatically generate
|
||||
** index pages for directories that don't have an explicit index file.
|
||||
3
web/converse/.gitignore
vendored
3
web/converse/.gitignore
vendored
|
|
@ -1,3 +0,0 @@
|
|||
.envrc
|
||||
/target/
|
||||
**/*.rs.bk
|
||||
3404
web/converse/Cargo.lock
generated
3404
web/converse/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,37 +0,0 @@
|
|||
[package]
|
||||
name = "converse"
|
||||
version = "0.1.0"
|
||||
authors = ["Vincent Ambo <mail@tazj.in>"]
|
||||
license = "GPL-3.0"
|
||||
|
||||
[dependencies]
|
||||
actix = "0.7"
|
||||
actix-web = "0.7"
|
||||
askama = "0.6"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
comrak = "0.2"
|
||||
crimp = "0.2"
|
||||
diesel = { version = "1.2", features = ["postgres", "chrono", "r2d2"]}
|
||||
env_logger = "0.5"
|
||||
failure = "0.1"
|
||||
futures = "0.1"
|
||||
hyper = "0.11"
|
||||
log = "0.4"
|
||||
md5 = "0.3.7"
|
||||
mime_guess = "2.0.0-alpha"
|
||||
pq-sys = "=0.4.4"
|
||||
r2d2 = "0.8"
|
||||
rand = "0.4"
|
||||
serde = "1.0"
|
||||
serde_derive = "1.0"
|
||||
serde_json = "1.0"
|
||||
tokio = "0.1"
|
||||
tokio-timer = "0.2"
|
||||
url = "1.7"
|
||||
url_serde = "0.2"
|
||||
curl = "*" # bounded by crimp
|
||||
rouille = "3.0"
|
||||
|
||||
[build-dependencies]
|
||||
pulldown-cmark = "0.1"
|
||||
askama = "0.6"
|
||||
|
|
@ -1,674 +0,0 @@
|
|||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
Converse
|
||||
========
|
||||
|
||||
Welcome to Converse, a work-in-progress forum software written in
|
||||
Rust. The intention behind Converse is to provide a simple forum-like
|
||||
experience.
|
||||
|
||||
There is not a lot of documentation about Converse yet and it has
|
||||
several known issues. Also note that Converse is being developed for a
|
||||
specific use-case and is not going to be a forum feature kitchen-sink
|
||||
like most classical forum softwares.
|
||||
|
||||
Better documentation is forthcoming once the remaining basics have
|
||||
been taken care of.
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
extern crate askama;
|
||||
|
||||
fn main() {
|
||||
askama::rerun_if_templates_changed();
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
{ depot, pkgs, ... }:
|
||||
|
||||
depot.third_party.naersk.buildPackage {
|
||||
src = ./.;
|
||||
buildInputs = with pkgs; [ openssl postgresql.lib ];
|
||||
nativeBuildInputs = [ pkgs.pkg-config ];
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
# This is an example configuration for running converse during
|
||||
# development using direnv:
|
||||
# https://github.com/direnv/direnv
|
||||
#
|
||||
# The OIDC actor is configured with bogus values as disabling logins
|
||||
# never causes it to run anyways.
|
||||
|
||||
export DATABASE_URL=postgres://converse:converse@localhost/converse
|
||||
export RUST_LOG=info
|
||||
export OIDC_DISCOVERY_URL=https://does.not.matter.com/
|
||||
export OIDC_CLIENT_ID=some-client-id
|
||||
export OIDC_CLIENT_SECRET='some-client-secret'
|
||||
export BASE_URL=http://localhost:4567
|
||||
export REQUIRE_LOGIN=false
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
-- This file was automatically created by Diesel to setup helper functions
|
||||
-- and other internal bookkeeping. This file is safe to edit, any future
|
||||
-- changes will be added to existing projects as new migrations.
|
||||
|
||||
DROP FUNCTION IF EXISTS diesel_manage_updated_at(_tbl regclass);
|
||||
DROP FUNCTION IF EXISTS diesel_set_updated_at();
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
-- This file was automatically created by Diesel to setup helper functions
|
||||
-- and other internal bookkeeping. This file is safe to edit, any future
|
||||
-- changes will be added to existing projects as new migrations.
|
||||
|
||||
|
||||
|
||||
|
||||
-- Sets up a trigger for the given table to automatically set a column called
|
||||
-- `updated_at` whenever the row is modified (unless `updated_at` was included
|
||||
-- in the modified columns)
|
||||
--
|
||||
-- # Example
|
||||
--
|
||||
-- ```sql
|
||||
-- CREATE TABLE users (id SERIAL PRIMARY KEY, updated_at TIMESTAMP NOT NULL DEFAULT NOW());
|
||||
--
|
||||
-- SELECT diesel_manage_updated_at('users');
|
||||
-- ```
|
||||
CREATE OR REPLACE FUNCTION diesel_manage_updated_at(_tbl regclass) RETURNS VOID AS $$
|
||||
BEGIN
|
||||
EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s
|
||||
FOR EACH ROW EXECUTE PROCEDURE diesel_set_updated_at()', _tbl);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE FUNCTION diesel_set_updated_at() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF (
|
||||
NEW IS DISTINCT FROM OLD AND
|
||||
NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at
|
||||
) THEN
|
||||
NEW.updated_at := current_timestamp;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
DROP TABLE posts;
|
||||
DROP TABLE threads;
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
CREATE TABLE threads (
|
||||
id SERIAL PRIMARY KEY,
|
||||
title VARCHAR NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
posted TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE posts (
|
||||
id SERIAL PRIMARY KEY,
|
||||
thread SERIAL REFERENCES threads (id),
|
||||
body TEXT NOT NULL,
|
||||
posted TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
|
@ -1 +0,0 @@
|
|||
ALTER TABLE posts RENAME COLUMN thread_id TO thread;
|
||||
|
|
@ -1 +0,0 @@
|
|||
ALTER TABLE posts RENAME COLUMN thread TO thread_id;
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
ALTER TABLE threads ALTER COLUMN posted DROP DEFAULT;
|
||||
ALTER TABLE posts ALTER COLUMN posted DROP DEFAULT;
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
ALTER TABLE threads ALTER COLUMN posted SET DEFAULT (NOW() AT TIME ZONE 'UTC');
|
||||
ALTER TABLE posts ALTER COLUMN posted SET DEFAULT (NOW() AT TIME ZONE 'UTC');
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
ALTER TABLE threads DROP COLUMN author_name;
|
||||
ALTER TABLE threads DROP COLUMN author_email;
|
||||
|
||||
ALTER TABLE posts DROP COLUMN author_name;
|
||||
ALTER TABLE posts DROP COLUMN author_email;
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
-- This migration adds an 'author' column to the thread & post table.
|
||||
-- Authors don't currently exist as independent objects in the
|
||||
-- database as most user management is simply delegated to the OIDC
|
||||
-- provider.
|
||||
|
||||
ALTER TABLE threads ADD COLUMN author_name VARCHAR NOT NULL DEFAULT 'anonymous';
|
||||
ALTER TABLE threads ADD COLUMN author_email VARCHAR NOT NULL DEFAULT 'unknown@example.org';
|
||||
|
||||
ALTER TABLE posts ADD COLUMN author_name VARCHAR NOT NULL DEFAULT 'anonymous';
|
||||
ALTER TABLE posts ADD COLUMN author_email VARCHAR NOT NULL DEFAULT 'unknown@example.org';
|
||||
|
|
@ -1 +0,0 @@
|
|||
ALTER TABLE threads ADD COLUMN body TEXT NOT NULL DEFAULT '';
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
-- Instead of storing the thread OP in the thread table, this will
|
||||
-- make it a post as well.
|
||||
-- At the time at which this migration was created no important data
|
||||
-- existed in any converse instances, so data is not moved.
|
||||
|
||||
ALTER TABLE threads DROP COLUMN body;
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
DROP VIEW thread_index;
|
||||
ALTER TABLE threads DROP COLUMN sticky;
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
-- Add support for stickies in threads
|
||||
ALTER TABLE threads ADD COLUMN sticky BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
-- CREATE a simple view that returns the list of threads ordered by
|
||||
-- the last post that occured in the thread.
|
||||
CREATE VIEW thread_index AS
|
||||
SELECT t.id AS thread_id,
|
||||
t.title AS title,
|
||||
t.author_name AS thread_author,
|
||||
t.posted AS created,
|
||||
t.sticky AS sticky,
|
||||
p.id AS post_id,
|
||||
p.author_name AS post_author,
|
||||
p.posted AS posted
|
||||
FROM threads t
|
||||
JOIN (SELECT DISTINCT ON (thread_id)
|
||||
id, thread_id, author_name, posted
|
||||
FROM posts
|
||||
ORDER BY thread_id, id DESC) AS p
|
||||
ON t.id = p.thread_id
|
||||
ORDER BY t.sticky DESC, p.id DESC;
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
DROP INDEX idx_fts_search;
|
||||
DROP MATERIALIZED VIEW search_index;
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
-- Prepare a materialised view containing the tsvector data for all
|
||||
-- threads and posts. This view is indexed using a GIN-index to enable
|
||||
-- performant full-text searches.
|
||||
--
|
||||
-- For now the query language is hardcoded to be English.
|
||||
|
||||
CREATE MATERIALIZED VIEW search_index AS
|
||||
SELECT p.id AS post_id,
|
||||
p.author_name AS author,
|
||||
t.id AS thread_id,
|
||||
t.title AS title,
|
||||
p.body AS body,
|
||||
setweight(to_tsvector('english', t.title), 'B') ||
|
||||
setweight(to_tsvector('english', p.body), 'A') ||
|
||||
setweight(to_tsvector('simple', t.author_name), 'C') ||
|
||||
setweight(to_tsvector('simple', p.author_name), 'C') AS document
|
||||
FROM posts p
|
||||
JOIN threads t
|
||||
ON t.id = p.thread_id;
|
||||
|
||||
CREATE INDEX idx_fts_search ON search_index USING gin(document);
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
-- First restore the old columns:
|
||||
ALTER TABLE threads ADD COLUMN author_name VARCHAR;
|
||||
ALTER TABLE threads ADD COLUMN author_email VARCHAR;
|
||||
ALTER TABLE posts ADD COLUMN author_name VARCHAR;
|
||||
ALTER TABLE posts ADD COLUMN author_email VARCHAR;
|
||||
|
||||
-- Then select the data back into them:
|
||||
UPDATE threads SET author_name = users.name,
|
||||
author_email = users.email
|
||||
FROM users
|
||||
WHERE threads.user_id = users.id;
|
||||
|
||||
UPDATE posts SET author_name = users.name,
|
||||
author_email = users.email
|
||||
FROM users
|
||||
WHERE posts.user_id = users.id;
|
||||
|
||||
-- add the constraints back:
|
||||
ALTER TABLE threads ALTER COLUMN author_name SET NOT NULL;
|
||||
ALTER TABLE threads ALTER COLUMN author_email SET NOT NULL;
|
||||
ALTER TABLE posts ALTER COLUMN author_name SET NOT NULL;
|
||||
ALTER TABLE posts ALTER COLUMN author_email SET NOT NULL;
|
||||
|
||||
-- reset the index view:
|
||||
CREATE OR REPLACE VIEW thread_index AS
|
||||
SELECT t.id AS thread_id,
|
||||
t.title AS title,
|
||||
t.author_name AS thread_author,
|
||||
t.posted AS created,
|
||||
t.sticky AS sticky,
|
||||
p.id AS post_id,
|
||||
p.author_name AS post_author,
|
||||
p.posted AS posted
|
||||
FROM threads t
|
||||
JOIN (SELECT DISTINCT ON (thread_id)
|
||||
id, thread_id, author_name, posted
|
||||
FROM posts
|
||||
ORDER BY thread_id, id DESC) AS p
|
||||
ON t.id = p.thread_id
|
||||
ORDER BY t.sticky DESC, p.id DESC;
|
||||
|
||||
-- reset the search view:
|
||||
DROP MATERIALIZED VIEW search_index;
|
||||
CREATE MATERIALIZED VIEW search_index AS
|
||||
SELECT p.id AS post_id,
|
||||
p.author_name AS author,
|
||||
t.id AS thread_id,
|
||||
t.title AS title,
|
||||
p.body AS body,
|
||||
setweight(to_tsvector('english', t.title), 'B') ||
|
||||
setweight(to_tsvector('english', p.body), 'A') ||
|
||||
setweight(to_tsvector('simple', t.author_name), 'C') ||
|
||||
setweight(to_tsvector('simple', p.author_name), 'C') AS document
|
||||
FROM posts p
|
||||
JOIN threads t
|
||||
ON t.id = p.thread_id;
|
||||
|
||||
CREATE INDEX idx_fts_search ON search_index USING gin(document);
|
||||
|
||||
-- and drop the users table and columns:
|
||||
ALTER TABLE posts DROP COLUMN user_id;
|
||||
ALTER TABLE threads DROP COLUMN user_id;
|
||||
DROP TABLE users;
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
-- This query creates a users table and migrates the existing user
|
||||
-- information (from the posts table) into it.
|
||||
|
||||
CREATE TABLE users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
email VARCHAR NOT NULL UNIQUE,
|
||||
name VARCHAR NOT NULL,
|
||||
admin BOOLEAN NOT NULL DEFAULT false
|
||||
);
|
||||
|
||||
-- Insert the 'anonymous' user explicitly:
|
||||
INSERT INTO users (name, email)
|
||||
VALUES ('Anonymous', 'anonymous@nothing.org');
|
||||
|
||||
INSERT INTO users (id, email, name)
|
||||
SELECT nextval('users_id_seq'),
|
||||
author_email AS email,
|
||||
author_name AS name
|
||||
FROM posts
|
||||
WHERE author_email != 'anonymous@nothing.org'
|
||||
GROUP BY name, email;
|
||||
|
||||
-- Create the 'user_id' column in the relevant tables (initially
|
||||
-- without a not-null constraint) and populate it with the data
|
||||
-- selected above:
|
||||
ALTER TABLE posts ADD COLUMN user_id INTEGER REFERENCES users (id);
|
||||
UPDATE posts SET user_id = users.id
|
||||
FROM users
|
||||
WHERE users.email = posts.author_email;
|
||||
|
||||
ALTER TABLE threads ADD COLUMN user_id INTEGER REFERENCES users (id);
|
||||
UPDATE threads SET user_id = users.id
|
||||
FROM users
|
||||
WHERE users.email = threads.author_email;
|
||||
|
||||
-- Add the constraints:
|
||||
ALTER TABLE posts ALTER COLUMN user_id SET NOT NULL;
|
||||
ALTER TABLE threads ALTER COLUMN user_id SET NOT NULL;
|
||||
|
||||
-- Update the index view:
|
||||
CREATE OR REPLACE VIEW thread_index AS
|
||||
SELECT t.id AS thread_id,
|
||||
t.title AS title,
|
||||
ta.name AS thread_author,
|
||||
t.posted AS created,
|
||||
t.sticky AS sticky,
|
||||
p.id AS post_id,
|
||||
pa.name AS post_author,
|
||||
p.posted AS posted
|
||||
FROM threads t
|
||||
JOIN (SELECT DISTINCT ON (thread_id)
|
||||
id, thread_id, user_id, posted
|
||||
FROM posts
|
||||
ORDER BY thread_id, id DESC) AS p
|
||||
ON t.id = p.thread_id
|
||||
JOIN users ta ON ta.id = t.user_id
|
||||
JOIN users pa ON pa.id = p.user_id
|
||||
ORDER BY t.sticky DESC, p.id DESC;
|
||||
|
||||
-- Update the search view:
|
||||
DROP MATERIALIZED VIEW search_index;
|
||||
CREATE MATERIALIZED VIEW search_index AS
|
||||
SELECT p.id AS post_id,
|
||||
pa.name AS author,
|
||||
t.id AS thread_id,
|
||||
t.title AS title,
|
||||
p.body AS body,
|
||||
setweight(to_tsvector('english', t.title), 'B') ||
|
||||
setweight(to_tsvector('english', p.body), 'A') ||
|
||||
setweight(to_tsvector('simple', ta.name), 'C') ||
|
||||
setweight(to_tsvector('simple', pa.name), 'C') AS document
|
||||
FROM posts p
|
||||
JOIN threads t ON t.id = p.thread_id
|
||||
JOIN users ta ON ta.id = t.user_id
|
||||
JOIN users pa ON pa.id = p.user_id;
|
||||
|
||||
CREATE INDEX idx_fts_search ON search_index USING gin(document);
|
||||
|
||||
-- And drop the old fields:
|
||||
ALTER TABLE posts DROP COLUMN author_name;
|
||||
ALTER TABLE posts DROP COLUMN author_email;
|
||||
ALTER TABLE threads DROP COLUMN author_name;
|
||||
ALTER TABLE threads DROP COLUMN author_email;
|
||||
|
|
@ -1 +0,0 @@
|
|||
DROP VIEW simple_posts;
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
-- Creates a view for listing posts akin to the post table before
|
||||
-- splitting out users. This exists to avoid having to do joining
|
||||
-- logic and such inside of the application.
|
||||
|
||||
CREATE VIEW simple_posts AS
|
||||
SELECT p.id AS id,
|
||||
thread_id, body, posted, user_id,
|
||||
users.name AS author_name,
|
||||
users.email AS author_email
|
||||
FROM posts p
|
||||
JOIN users ON users.id = p.user_id;
|
||||
|
|
@ -1 +0,0 @@
|
|||
ALTER TABLE threads DROP COLUMN closed;
|
||||
|
|
@ -1 +0,0 @@
|
|||
ALTER TABLE threads ADD COLUMN closed BOOLEAN NOT NULL DEFAULT false;
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
-- Update the index view:
|
||||
DROP VIEW thread_index;
|
||||
CREATE VIEW thread_index AS
|
||||
SELECT t.id AS thread_id,
|
||||
t.title AS title,
|
||||
ta.name AS thread_author,
|
||||
t.posted AS created,
|
||||
t.sticky AS sticky,
|
||||
p.id AS post_id,
|
||||
pa.name AS post_author,
|
||||
p.posted AS posted
|
||||
FROM threads t
|
||||
JOIN (SELECT DISTINCT ON (thread_id)
|
||||
id, thread_id, user_id, posted
|
||||
FROM posts
|
||||
ORDER BY thread_id, id DESC) AS p
|
||||
ON t.id = p.thread_id
|
||||
JOIN users ta ON ta.id = t.user_id
|
||||
JOIN users pa ON pa.id = p.user_id
|
||||
ORDER BY t.sticky DESC, p.id DESC;
|
||||
|
||||
-- Update the post view:
|
||||
DROP VIEW simple_posts;
|
||||
CREATE VIEW simple_posts AS
|
||||
SELECT p.id AS id,
|
||||
thread_id, body, posted, user_id,
|
||||
users.name AS author_name,
|
||||
users.email AS author_email
|
||||
FROM posts p
|
||||
JOIN users ON users.id = p.user_id;
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
-- Update the index view:
|
||||
DROP VIEW thread_index;
|
||||
CREATE VIEW thread_index AS
|
||||
SELECT t.id AS thread_id,
|
||||
t.title AS title,
|
||||
ta.name AS thread_author,
|
||||
t.posted AS created,
|
||||
t.sticky AS sticky,
|
||||
t.closed AS closed,
|
||||
p.id AS post_id,
|
||||
pa.name AS post_author,
|
||||
p.posted AS posted
|
||||
FROM threads t
|
||||
JOIN (SELECT DISTINCT ON (thread_id)
|
||||
id, thread_id, user_id, posted
|
||||
FROM posts
|
||||
ORDER BY thread_id, id DESC) AS p
|
||||
ON t.id = p.thread_id
|
||||
JOIN users ta ON ta.id = t.user_id
|
||||
JOIN users pa ON pa.id = p.user_id
|
||||
ORDER BY t.sticky DESC, p.id DESC;
|
||||
|
||||
-- Update post view:
|
||||
DROP VIEW simple_posts;
|
||||
CREATE VIEW simple_posts AS
|
||||
SELECT p.id AS id,
|
||||
thread_id, body,
|
||||
p.posted AS posted,
|
||||
p.user_id AS user_id,
|
||||
threads.closed AS closed,
|
||||
users.name AS author_name,
|
||||
users.email AS author_email
|
||||
FROM posts p
|
||||
JOIN users ON users.id = p.user_id
|
||||
JOIN threads ON threads.id = p.thread_id;
|
||||
|
|
@ -1,317 +0,0 @@
|
|||
// Copyright (C) 2018-2021 Vincent Ambo <tazjin@tvl.su>
|
||||
//
|
||||
// This file is part of Converse.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or
|
||||
// modify it under the terms of the GNU General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful, but
|
||||
// WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
// General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see
|
||||
// <https://www.gnu.org/licenses/>.
|
||||
|
||||
//! This module implements the database executor, which holds the
|
||||
//! database connection and performs queries on it.
|
||||
|
||||
use crate::errors::{ConverseError, Result};
|
||||
use crate::models::*;
|
||||
use actix::prelude::*;
|
||||
use diesel::prelude::*;
|
||||
use diesel::r2d2::{ConnectionManager, Pool};
|
||||
use diesel::sql_types::Text;
|
||||
use diesel::{self, sql_query};
|
||||
|
||||
/// Raw PostgreSQL query used to perform full-text search on posts
|
||||
/// with a supplied phrase. For now, the query language is hardcoded
|
||||
/// to English and only "plain" queries (i.e. no searches for exact
|
||||
/// matches or more advanced query syntax) are supported.
|
||||
const SEARCH_QUERY: &'static str = r#"
|
||||
WITH search_query (query) AS (VALUES (plainto_tsquery('english', $1)))
|
||||
SELECT post_id,
|
||||
thread_id,
|
||||
author,
|
||||
title,
|
||||
ts_headline('english', body, query) AS headline
|
||||
FROM search_index, search_query
|
||||
WHERE document @@ query
|
||||
ORDER BY ts_rank(document, query) DESC
|
||||
LIMIT 50
|
||||
"#;
|
||||
|
||||
const REFRESH_QUERY: &'static str = "REFRESH MATERIALIZED VIEW search_index";
|
||||
|
||||
pub struct DbExecutor(pub Pool<ConnectionManager<PgConnection>>);
|
||||
|
||||
impl DbExecutor {
|
||||
/// Request a list of threads.
|
||||
// TODO(tazjin): This should support pagination.
|
||||
pub fn list_threads(&self) -> Result<Vec<ThreadIndex>> {
|
||||
use crate::schema::thread_index::dsl::*;
|
||||
|
||||
let conn = self.0.get()?;
|
||||
let results = thread_index.load::<ThreadIndex>(&conn)?;
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Look up a user based on their email-address. If the user does
|
||||
/// not exist, it is created.
|
||||
pub fn lookup_or_create_user(&self, user_email: &str, user_name: &str) -> Result<User> {
|
||||
use crate::schema::users;
|
||||
use crate::schema::users::dsl::*;
|
||||
|
||||
let conn = self.0.get()?;
|
||||
|
||||
let opt_user = users.filter(email.eq(email)).first(&conn).optional()?;
|
||||
|
||||
if let Some(user) = opt_user {
|
||||
Ok(user)
|
||||
} else {
|
||||
let new_user = NewUser {
|
||||
email: user_email.to_string(),
|
||||
name: user_name.to_string(),
|
||||
};
|
||||
|
||||
let user: User = diesel::insert_into(users::table)
|
||||
.values(&new_user)
|
||||
.get_result(&conn)?;
|
||||
|
||||
info!("Created new user {} with ID {}", new_user.email, user.id);
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch a specific thread and return it with its posts.
|
||||
pub fn get_thread(&self, thread_id: i32) -> Result<(Thread, Vec<SimplePost>)> {
|
||||
use crate::schema::simple_posts::dsl::id;
|
||||
use crate::schema::threads::dsl::*;
|
||||
|
||||
let conn = self.0.get()?;
|
||||
let thread_result: Thread = threads.find(thread_id).first(&conn)?;
|
||||
|
||||
let post_list = SimplePost::belonging_to(&thread_result)
|
||||
.order_by(id.asc())
|
||||
.load::<SimplePost>(&conn)?;
|
||||
|
||||
Ok((thread_result, post_list))
|
||||
}
|
||||
|
||||
/// Fetch a specific post.
|
||||
pub fn get_post(&self, post_id: i32) -> Result<SimplePost> {
|
||||
use crate::schema::simple_posts::dsl::*;
|
||||
let conn = self.0.get()?;
|
||||
Ok(simple_posts.find(post_id).first(&conn)?)
|
||||
}
|
||||
|
||||
/// Update the content of a post.
|
||||
pub fn update_post(&self, post_id: i32, post_text: String) -> Result<Post> {
|
||||
use crate::schema::posts::dsl::*;
|
||||
let conn = self.0.get()?;
|
||||
let updated = diesel::update(posts.find(post_id))
|
||||
.set(body.eq(post_text))
|
||||
.get_result(&conn)?;
|
||||
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
/// Create a new thread.
|
||||
pub fn create_thread(&self, new_thread: NewThread, post_text: String) -> Result<Thread> {
|
||||
use crate::schema::{posts, threads};
|
||||
|
||||
let conn = self.0.get()?;
|
||||
|
||||
conn.transaction::<Thread, ConverseError, _>(|| {
|
||||
// First insert the thread structure itself
|
||||
let thread: Thread = diesel::insert_into(threads::table)
|
||||
.values(&new_thread)
|
||||
.get_result(&conn)?;
|
||||
|
||||
// ... then create the first post in the thread.
|
||||
let new_post = NewPost {
|
||||
thread_id: thread.id,
|
||||
body: post_text,
|
||||
user_id: new_thread.user_id,
|
||||
};
|
||||
|
||||
diesel::insert_into(posts::table)
|
||||
.values(&new_post)
|
||||
.execute(&conn)?;
|
||||
|
||||
Ok(thread)
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a new post.
|
||||
pub fn create_post(&self, new_post: NewPost) -> Result<Post> {
|
||||
use crate::schema::posts;
|
||||
|
||||
let conn = self.0.get()?;
|
||||
|
||||
let closed: bool = {
|
||||
use crate::schema::threads::dsl::*;
|
||||
threads
|
||||
.select(closed)
|
||||
.find(new_post.thread_id)
|
||||
.first(&conn)?
|
||||
};
|
||||
|
||||
if closed {
|
||||
return Err(ConverseError::ThreadClosed {
|
||||
id: new_post.thread_id,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(diesel::insert_into(posts::table)
|
||||
.values(&new_post)
|
||||
.get_result(&conn)?)
|
||||
}
|
||||
|
||||
/// Search for posts.
|
||||
pub fn search_posts(&self, query: String) -> Result<Vec<SearchResult>> {
|
||||
let conn = self.0.get()?;
|
||||
|
||||
let search_results = sql_query(SEARCH_QUERY)
|
||||
.bind::<Text, _>(query)
|
||||
.get_results::<SearchResult>(&conn)?;
|
||||
|
||||
Ok(search_results)
|
||||
}
|
||||
|
||||
/// Trigger a refresh of the view used for full-text searching.
|
||||
pub fn refresh_search_view(&self) -> Result<()> {
|
||||
let conn = self.0.get()?;
|
||||
debug!("Refreshing search_index view in DB");
|
||||
sql_query(REFRESH_QUERY).execute(&conn)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// Old actor implementation:
|
||||
|
||||
impl Actor for DbExecutor {
|
||||
type Context = SyncContext<Self>;
|
||||
}
|
||||
|
||||
/// Message used to look up a user based on their email-address. If
|
||||
/// the user does not exist, it is created.
|
||||
pub struct LookupOrCreateUser {
|
||||
pub email: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
message!(LookupOrCreateUser, Result<User>);
|
||||
|
||||
impl Handler<LookupOrCreateUser> for DbExecutor {
|
||||
type Result = <LookupOrCreateUser as Message>::Result;
|
||||
|
||||
fn handle(&mut self, _: LookupOrCreateUser, _: &mut Self::Context) -> Self::Result {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
|
||||
/// Message used to fetch a specific thread. Returns the thread and
|
||||
/// its posts.
|
||||
pub struct GetThread(pub i32);
|
||||
message!(GetThread, Result<(Thread, Vec<SimplePost>)>);
|
||||
|
||||
impl Handler<GetThread> for DbExecutor {
|
||||
type Result = <GetThread as Message>::Result;
|
||||
|
||||
fn handle(&mut self, _: GetThread, _: &mut Self::Context) -> Self::Result {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
|
||||
/// Message used to fetch a specific post.
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct GetPost {
|
||||
pub id: i32,
|
||||
}
|
||||
|
||||
message!(GetPost, Result<SimplePost>);
|
||||
|
||||
impl Handler<GetPost> for DbExecutor {
|
||||
type Result = <GetPost as Message>::Result;
|
||||
|
||||
fn handle(&mut self, _: GetPost, _: &mut Self::Context) -> Self::Result {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
|
||||
/// Message used to update the content of a post.
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdatePost {
|
||||
pub post_id: i32,
|
||||
pub post: String,
|
||||
}
|
||||
|
||||
message!(UpdatePost, Result<Post>);
|
||||
|
||||
impl Handler<UpdatePost> for DbExecutor {
|
||||
type Result = Result<Post>;
|
||||
|
||||
fn handle(&mut self, _: UpdatePost, _: &mut Self::Context) -> Self::Result {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
|
||||
/// Message used to create a new thread
|
||||
pub struct CreateThread {
|
||||
pub new_thread: NewThread,
|
||||
pub post: String,
|
||||
}
|
||||
message!(CreateThread, Result<Thread>);
|
||||
|
||||
impl Handler<CreateThread> for DbExecutor {
|
||||
type Result = <CreateThread as Message>::Result;
|
||||
|
||||
fn handle(&mut self, _: CreateThread, _: &mut Self::Context) -> Self::Result {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
|
||||
/// Message used to create a new reply
|
||||
pub struct CreatePost(pub NewPost);
|
||||
message!(CreatePost, Result<Post>);
|
||||
|
||||
impl Handler<CreatePost> for DbExecutor {
|
||||
type Result = <CreatePost as Message>::Result;
|
||||
|
||||
fn handle(&mut self, _: CreatePost, _: &mut Self::Context) -> Self::Result {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
|
||||
/// Message used to search for posts
|
||||
#[derive(Deserialize)]
|
||||
pub struct SearchPosts {
|
||||
pub query: String,
|
||||
}
|
||||
message!(SearchPosts, Result<Vec<SearchResult>>);
|
||||
|
||||
impl Handler<SearchPosts> for DbExecutor {
|
||||
type Result = <SearchPosts as Message>::Result;
|
||||
|
||||
fn handle(&mut self, _: SearchPosts, _: &mut Self::Context) -> Self::Result {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
|
||||
/// Message that triggers a refresh of the view used for full-text
|
||||
/// searching.
|
||||
pub struct RefreshSearchView;
|
||||
message!(RefreshSearchView, Result<()>);
|
||||
|
||||
impl Handler<RefreshSearchView> for DbExecutor {
|
||||
type Result = Result<()>;
|
||||
|
||||
fn handle(&mut self, _: RefreshSearchView, _: &mut Self::Context) -> Self::Result {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,139 +0,0 @@
|
|||
// Copyright (C) 2018-2021 Vincent Ambo <tazjin@tvl.su>
|
||||
//
|
||||
// This file is part of Converse.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or
|
||||
// modify it under the terms of the GNU General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful, but
|
||||
// WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
// General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see
|
||||
// <https://www.gnu.org/licenses/>.
|
||||
|
||||
//! This module defines custom error types using the `failure`-crate.
|
||||
//! Links to foreign error types (such as database connection errors)
|
||||
//! are established in a similar way as was tradition in
|
||||
//! `error_chain`, albeit manually.
|
||||
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::{HttpResponse, ResponseError};
|
||||
use std::result;
|
||||
|
||||
// Modules with foreign errors:
|
||||
use {actix, actix_web, askama, diesel, r2d2, tokio_timer};
|
||||
|
||||
pub type Result<T> = result::Result<T, ConverseError>;
|
||||
pub type ConverseResult<T> = result::Result<T, ConverseError>;
|
||||
|
||||
#[derive(Debug, Fail)]
|
||||
pub enum ConverseError {
|
||||
#[fail(display = "an internal Converse error occured: {}", reason)]
|
||||
InternalError { reason: String },
|
||||
|
||||
#[fail(display = "a database error occured: {}", error)]
|
||||
Database { error: diesel::result::Error },
|
||||
|
||||
#[fail(display = "a database connection pool error occured: {}", error)]
|
||||
ConnectionPool { error: r2d2::Error },
|
||||
|
||||
#[fail(display = "a template rendering error occured: {}", reason)]
|
||||
Template { reason: String },
|
||||
|
||||
#[fail(display = "error occured during request handling: {}", error)]
|
||||
ActixWeb { error: actix_web::Error },
|
||||
|
||||
#[fail(display = "error occured running timer: {}", error)]
|
||||
Timer { error: tokio_timer::Error },
|
||||
|
||||
#[fail(display = "user {} does not have permission to edit post {}", user, id)]
|
||||
PostEditForbidden { user: i32, id: i32 },
|
||||
|
||||
#[fail(display = "thread {} is closed and can not be responded to", id)]
|
||||
ThreadClosed { id: i32 },
|
||||
|
||||
#[fail(display = "JSON serialisation failed: {}", error)]
|
||||
Serialisation { error: serde_json::Error },
|
||||
|
||||
// This variant is used as a catch-all for wrapping
|
||||
// actix-web-compatible response errors, such as the errors it
|
||||
// throws itself.
|
||||
#[fail(display = "Actix response error: {}", error)]
|
||||
Actix { error: Box<dyn ResponseError> },
|
||||
}
|
||||
|
||||
// Establish conversion links to foreign errors:
|
||||
|
||||
impl From<diesel::result::Error> for ConverseError {
|
||||
fn from(error: diesel::result::Error) -> ConverseError {
|
||||
ConverseError::Database { error }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<r2d2::Error> for ConverseError {
|
||||
fn from(error: r2d2::Error) -> ConverseError {
|
||||
ConverseError::ConnectionPool { error }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<askama::Error> for ConverseError {
|
||||
fn from(error: askama::Error) -> ConverseError {
|
||||
ConverseError::Template {
|
||||
reason: format!("{}", error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<actix::MailboxError> for ConverseError {
|
||||
fn from(error: actix::MailboxError) -> ConverseError {
|
||||
ConverseError::Actix {
|
||||
error: Box::new(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<actix_web::Error> for ConverseError {
|
||||
fn from(error: actix_web::Error) -> ConverseError {
|
||||
ConverseError::ActixWeb { error }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for ConverseError {
|
||||
fn from(error: serde_json::Error) -> ConverseError {
|
||||
ConverseError::Serialisation { error }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<curl::Error> for ConverseError {
|
||||
fn from(error: curl::Error) -> ConverseError {
|
||||
ConverseError::InternalError {
|
||||
reason: format!("error during HTTP request: {}", error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<tokio_timer::Error> for ConverseError {
|
||||
fn from(error: tokio_timer::Error) -> ConverseError {
|
||||
ConverseError::Timer { error }
|
||||
}
|
||||
}
|
||||
|
||||
// Support conversion of error type into HTTP error responses:
|
||||
|
||||
impl ResponseError for ConverseError {
|
||||
fn error_response(&self) -> HttpResponse {
|
||||
// Everything is mapped to internal server errors for now.
|
||||
match *self {
|
||||
ConverseError::ThreadClosed { id } => HttpResponse::SeeOther()
|
||||
.header("Location", format!("/thread/{}#post-reply", id))
|
||||
.finish(),
|
||||
_ => HttpResponse::build(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.body(format!("An error occured: {}", self)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,391 +0,0 @@
|
|||
// Copyright (C) 2018-2021 Vincent Ambo <tazjin@tvl.su>
|
||||
//
|
||||
// This file is part of Converse.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or
|
||||
// modify it under the terms of the GNU General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful, but
|
||||
// WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
// General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see
|
||||
// <https://www.gnu.org/licenses/>.
|
||||
|
||||
//! This module contains the implementation of converse's actix-web
|
||||
//! HTTP handlers.
|
||||
//!
|
||||
//! Most handlers have an associated rendering function using one of
|
||||
//! the tera templates stored in the `/templates` directory in the
|
||||
//! project root.
|
||||
|
||||
use crate::db::*;
|
||||
use crate::errors::{ConverseError, ConverseResult};
|
||||
use crate::models::*;
|
||||
use crate::oidc::*;
|
||||
use crate::render::*;
|
||||
use actix::prelude::*;
|
||||
use actix_web;
|
||||
use actix_web::http::Method;
|
||||
use actix_web::middleware::identity::RequestIdentity;
|
||||
use actix_web::middleware::{Middleware, Started};
|
||||
use actix_web::*;
|
||||
use futures::Future;
|
||||
|
||||
use rouille::{Request, Response};
|
||||
|
||||
type ConverseResponse = Box<dyn Future<Item = HttpResponse, Error = ConverseError>>;
|
||||
|
||||
const HTML: &'static str = "text/html";
|
||||
const ANONYMOUS: i32 = 1;
|
||||
const NEW_THREAD_LENGTH_ERR: &'static str = "Title and body can not be empty!";
|
||||
|
||||
/// Represents the state carried by the web server actors.
|
||||
pub struct AppState {
|
||||
/// Address of the database actor
|
||||
pub db: Addr<DbExecutor>,
|
||||
|
||||
/// Address of the OIDC actor
|
||||
pub oidc: Addr<OidcExecutor>,
|
||||
|
||||
/// Address of the rendering actor
|
||||
pub renderer: Addr<Renderer>,
|
||||
}
|
||||
|
||||
/// Serve the forum's index page.
|
||||
pub fn forum_index_rouille(db: &DbExecutor) -> ConverseResult<Response> {
|
||||
let threads = db.list_threads()?;
|
||||
Ok(Response::html(index_page(threads)?))
|
||||
}
|
||||
|
||||
pub fn forum_index(_: State<AppState>) -> ConverseResponse {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
/// Returns the ID of the currently logged in user. If there is no ID
|
||||
/// present, the ID of the anonymous user will be returned.
|
||||
pub fn get_user_id(req: &HttpRequest<AppState>) -> i32 {
|
||||
if let Some(id) = req.identity() {
|
||||
// If this .expect() call is triggered, someone is likely
|
||||
// attempting to mess with their cookies. These requests can
|
||||
// be allowed to fail without further ado.
|
||||
id.parse().expect("Session cookie contained invalid data!")
|
||||
} else {
|
||||
ANONYMOUS
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_user_id_rouille(_req: &Request) -> i32 {
|
||||
// TODO(tazjin): Implement session support in rouille somehow.
|
||||
ANONYMOUS
|
||||
}
|
||||
|
||||
pub fn forum_thread_rouille(
|
||||
req: &Request,
|
||||
db: &DbExecutor,
|
||||
thread_id: i32,
|
||||
) -> ConverseResult<Response> {
|
||||
let user = get_user_id_rouille(&req);
|
||||
let thread = db.get_thread(thread_id)?;
|
||||
Ok(Response::html(thread_page(user, thread.0, thread.1)?))
|
||||
}
|
||||
|
||||
/// This handler retrieves and displays a single forum thread.
|
||||
pub fn forum_thread(
|
||||
_: State<AppState>,
|
||||
_: HttpRequest<AppState>,
|
||||
_: Path<i32>,
|
||||
) -> ConverseResponse {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
/// This handler presents the user with the "New Thread" form.
|
||||
pub fn new_thread(state: State<AppState>) -> ConverseResponse {
|
||||
state
|
||||
.renderer
|
||||
.send(NewThreadPage::default())
|
||||
.flatten()
|
||||
.map(|res| HttpResponse::Ok().content_type(HTML).body(res))
|
||||
.responder()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct NewThreadForm {
|
||||
pub title: String,
|
||||
pub post: String,
|
||||
}
|
||||
|
||||
/// This handler receives a "New thread"-form and redirects the user
|
||||
/// to the new thread after creation.
|
||||
pub fn submit_thread(
|
||||
(state, input, req): (State<AppState>, Form<NewThreadForm>, HttpRequest<AppState>),
|
||||
) -> ConverseResponse {
|
||||
// Trim whitespace out of inputs:
|
||||
let input = NewThreadForm {
|
||||
title: input.title.trim().into(),
|
||||
post: input.post.trim().into(),
|
||||
};
|
||||
|
||||
// Perform simple validation and abort here if it fails:
|
||||
if input.title.is_empty() || input.post.is_empty() {
|
||||
return state
|
||||
.renderer
|
||||
.send(NewThreadPage {
|
||||
alerts: vec![NEW_THREAD_LENGTH_ERR],
|
||||
title: Some(input.title),
|
||||
post: Some(input.post),
|
||||
})
|
||||
.flatten()
|
||||
.map(|res| HttpResponse::Ok().content_type(HTML).body(res))
|
||||
.responder();
|
||||
}
|
||||
|
||||
let user_id = get_user_id(&req);
|
||||
|
||||
let new_thread = NewThread {
|
||||
user_id,
|
||||
title: input.title,
|
||||
};
|
||||
|
||||
let msg = CreateThread {
|
||||
new_thread,
|
||||
post: input.post,
|
||||
};
|
||||
|
||||
state
|
||||
.db
|
||||
.send(msg)
|
||||
.from_err()
|
||||
.and_then(move |res| {
|
||||
let thread = res?;
|
||||
info!(
|
||||
"Created new thread \"{}\" with ID {}",
|
||||
thread.title, thread.id
|
||||
);
|
||||
Ok(HttpResponse::SeeOther()
|
||||
.header("Location", format!("/thread/{}", thread.id))
|
||||
.finish())
|
||||
})
|
||||
.responder()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct NewPostForm {
|
||||
pub thread_id: i32,
|
||||
pub post: String,
|
||||
}
|
||||
|
||||
/// This handler receives a "Reply"-form and redirects the user to the
|
||||
/// new post after creation.
|
||||
pub fn reply_thread(
|
||||
state: State<AppState>,
|
||||
input: Form<NewPostForm>,
|
||||
req: HttpRequest<AppState>,
|
||||
) -> ConverseResponse {
|
||||
let user_id = get_user_id(&req);
|
||||
|
||||
let new_post = NewPost {
|
||||
user_id,
|
||||
thread_id: input.thread_id,
|
||||
body: input.post.trim().into(),
|
||||
};
|
||||
|
||||
state
|
||||
.db
|
||||
.send(CreatePost(new_post))
|
||||
.flatten()
|
||||
.from_err()
|
||||
.and_then(move |post| {
|
||||
info!("Posted reply {} to thread {}", post.id, post.thread_id);
|
||||
Ok(HttpResponse::SeeOther()
|
||||
.header(
|
||||
"Location",
|
||||
format!("/thread/{}#post-{}", post.thread_id, post.id),
|
||||
)
|
||||
.finish())
|
||||
})
|
||||
.responder()
|
||||
}
|
||||
|
||||
/// This handler presents the user with the form to edit a post. If
|
||||
/// the user attempts to edit a post that they do not have access to,
|
||||
/// they are currently ungracefully redirected back to the post
|
||||
/// itself.
|
||||
pub fn edit_form(
|
||||
state: State<AppState>,
|
||||
req: HttpRequest<AppState>,
|
||||
query: Path<GetPost>,
|
||||
) -> ConverseResponse {
|
||||
let user_id = get_user_id(&req);
|
||||
|
||||
state
|
||||
.db
|
||||
.send(query.into_inner())
|
||||
.flatten()
|
||||
.from_err()
|
||||
.and_then(move |post| {
|
||||
if user_id != 1 && post.user_id == user_id {
|
||||
return Ok(post);
|
||||
}
|
||||
|
||||
Err(ConverseError::PostEditForbidden {
|
||||
user: user_id,
|
||||
id: post.id,
|
||||
})
|
||||
})
|
||||
.and_then(move |post| {
|
||||
let edit_msg = EditPostPage {
|
||||
id: post.id,
|
||||
post: post.body,
|
||||
};
|
||||
|
||||
state.renderer.send(edit_msg).from_err()
|
||||
})
|
||||
.flatten()
|
||||
.map(|page| HttpResponse::Ok().content_type(HTML).body(page))
|
||||
.responder()
|
||||
}
|
||||
|
||||
/// This handler "executes" an edit to a post if the current user owns
|
||||
/// the edited post.
|
||||
pub fn edit_post(
|
||||
state: State<AppState>,
|
||||
req: HttpRequest<AppState>,
|
||||
update: Form<UpdatePost>,
|
||||
) -> ConverseResponse {
|
||||
let user_id = get_user_id(&req);
|
||||
|
||||
state
|
||||
.db
|
||||
.send(GetPost { id: update.post_id })
|
||||
.flatten()
|
||||
.from_err()
|
||||
.and_then(move |post| {
|
||||
if user_id != 1 && post.user_id == user_id {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ConverseError::PostEditForbidden {
|
||||
user: user_id,
|
||||
id: post.id,
|
||||
})
|
||||
}
|
||||
})
|
||||
.and_then(move |_| state.db.send(update.0).from_err())
|
||||
.flatten()
|
||||
.map(|updated| {
|
||||
HttpResponse::SeeOther()
|
||||
.header(
|
||||
"Location",
|
||||
format!("/thread/{}#post-{}", updated.thread_id, updated.id),
|
||||
)
|
||||
.finish()
|
||||
})
|
||||
.responder()
|
||||
}
|
||||
|
||||
/// This handler executes a full-text search on the forum database and
|
||||
/// displays the results to the user.
|
||||
pub fn search_forum(state: State<AppState>, query: Query<SearchPosts>) -> ConverseResponse {
|
||||
let query_string = query.query.clone();
|
||||
state
|
||||
.db
|
||||
.send(query.into_inner())
|
||||
.flatten()
|
||||
.and_then(move |results| {
|
||||
state
|
||||
.renderer
|
||||
.send(SearchResultPage {
|
||||
results,
|
||||
query: query_string,
|
||||
})
|
||||
.from_err()
|
||||
})
|
||||
.flatten()
|
||||
.map(|res| HttpResponse::Ok().content_type(HTML).body(res))
|
||||
.responder()
|
||||
}
|
||||
|
||||
/// This handler initiates an OIDC login.
|
||||
pub fn login(state: State<AppState>) -> ConverseResponse {
|
||||
state
|
||||
.oidc
|
||||
.send(GetLoginUrl)
|
||||
.from_err()
|
||||
.and_then(|url| {
|
||||
Ok(HttpResponse::TemporaryRedirect()
|
||||
.header("Location", url)
|
||||
.finish())
|
||||
})
|
||||
.responder()
|
||||
}
|
||||
|
||||
/// This handler handles an OIDC callback (i.e. completed login).
|
||||
///
|
||||
/// Upon receiving the callback, a token is retrieved from the OIDC
|
||||
/// provider and a user lookup is performed. If a user with a matching
|
||||
/// email-address is found in the database, it is logged in -
|
||||
/// otherwise a new user is created.
|
||||
pub fn callback(
|
||||
state: State<AppState>,
|
||||
data: Form<CodeResponse>,
|
||||
req: HttpRequest<AppState>,
|
||||
) -> ConverseResponse {
|
||||
state
|
||||
.oidc
|
||||
.send(RetrieveToken(data.0))
|
||||
.flatten()
|
||||
.map(|author| LookupOrCreateUser {
|
||||
email: author.email,
|
||||
name: author.name,
|
||||
})
|
||||
.and_then(move |msg| state.db.send(msg).from_err())
|
||||
.flatten()
|
||||
.and_then(move |user| {
|
||||
info!("Completed login for user {} ({})", user.email, user.id);
|
||||
req.remember(user.id.to_string());
|
||||
Ok(HttpResponse::SeeOther().header("Location", "/").finish())
|
||||
})
|
||||
.responder()
|
||||
}
|
||||
|
||||
/// This is an extension trait to enable easy serving of embedded
|
||||
/// static content.
|
||||
///
|
||||
/// It is intended to be called with `include_bytes!()` when setting
|
||||
/// up the actix-web application.
|
||||
pub trait EmbeddedFile {
|
||||
fn static_file(self, path: &'static str, content: &'static [u8]) -> Self;
|
||||
}
|
||||
|
||||
impl EmbeddedFile for App<AppState> {
|
||||
fn static_file(self, path: &'static str, content: &'static [u8]) -> Self {
|
||||
self.route(path, Method::GET, move |_: HttpRequest<_>| {
|
||||
let mime = format!("{}", mime_guess::from_path(path).first_or_octet_stream());
|
||||
HttpResponse::Ok().content_type(mime.as_str()).body(content)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Middleware used to enforce logins unceremoniously.
|
||||
pub struct RequireLogin;
|
||||
|
||||
impl<S> Middleware<S> for RequireLogin {
|
||||
fn start(&self, req: &HttpRequest<S>) -> actix_web::Result<Started> {
|
||||
let logged_in = req.identity().is_some();
|
||||
let is_oidc_req = req.path().starts_with("/oidc");
|
||||
|
||||
if !is_oidc_req && !logged_in {
|
||||
Ok(Started::Response(
|
||||
HttpResponse::SeeOther()
|
||||
.header("Location", "/oidc/login")
|
||||
.finish(),
|
||||
))
|
||||
} else {
|
||||
Ok(Started::Done)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,239 +0,0 @@
|
|||
// Copyright (C) 2018-2021 Vincent Ambo <tazjin@tvl.su>
|
||||
//
|
||||
// This file is part of Converse.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or
|
||||
// modify it under the terms of the GNU General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful, but
|
||||
// WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
// General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see
|
||||
// <https://www.gnu.org/licenses/>.
|
||||
|
||||
extern crate askama;
|
||||
|
||||
#[macro_use]
|
||||
extern crate diesel;
|
||||
|
||||
#[macro_use]
|
||||
extern crate failure;
|
||||
|
||||
#[macro_use]
|
||||
extern crate log;
|
||||
|
||||
#[macro_use]
|
||||
extern crate serde_derive;
|
||||
|
||||
extern crate actix;
|
||||
extern crate actix_web;
|
||||
extern crate chrono;
|
||||
extern crate comrak;
|
||||
extern crate crimp;
|
||||
extern crate curl;
|
||||
extern crate env_logger;
|
||||
extern crate futures;
|
||||
extern crate hyper;
|
||||
extern crate md5;
|
||||
extern crate mime_guess;
|
||||
extern crate r2d2;
|
||||
extern crate rand;
|
||||
extern crate rouille;
|
||||
extern crate serde;
|
||||
extern crate serde_json;
|
||||
extern crate tokio;
|
||||
extern crate tokio_timer;
|
||||
extern crate url;
|
||||
extern crate url_serde;
|
||||
|
||||
/// Simple macro used to reduce boilerplate when defining actor
|
||||
/// message types.
|
||||
macro_rules! message {
|
||||
( $t:ty, $r:ty ) => {
|
||||
impl Message for $t {
|
||||
type Result = $r;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
pub mod db;
|
||||
pub mod errors;
|
||||
pub mod handlers;
|
||||
pub mod models;
|
||||
pub mod oidc;
|
||||
pub mod render;
|
||||
pub mod schema;
|
||||
|
||||
use crate::db::*;
|
||||
use crate::handlers::*;
|
||||
use crate::oidc::OidcExecutor;
|
||||
use crate::render::Renderer;
|
||||
use actix::prelude::*;
|
||||
use actix_web::http::Method;
|
||||
use actix_web::middleware::identity::{CookieIdentityPolicy, IdentityService};
|
||||
use actix_web::middleware::Logger;
|
||||
use actix_web::*;
|
||||
use diesel::pg::PgConnection;
|
||||
use diesel::r2d2::{ConnectionManager, Pool};
|
||||
use rand::{OsRng, Rng};
|
||||
use std::env;
|
||||
|
||||
fn config(name: &str) -> String {
|
||||
env::var(name).expect(&format!("{} must be set", name))
|
||||
}
|
||||
|
||||
fn config_default(name: &str, default: &str) -> String {
|
||||
env::var(name).unwrap_or(default.into())
|
||||
}
|
||||
|
||||
fn start_db_executor() -> Addr<DbExecutor> {
|
||||
info!("Initialising database connection pool ...");
|
||||
let db_url = config("DATABASE_URL");
|
||||
|
||||
let manager = ConnectionManager::<PgConnection>::new(db_url);
|
||||
let pool = Pool::builder()
|
||||
.build(manager)
|
||||
.expect("Failed to initialise DB pool");
|
||||
|
||||
SyncArbiter::start(2, move || DbExecutor(pool.clone()))
|
||||
}
|
||||
|
||||
fn schedule_search_refresh(db: Addr<DbExecutor>) {
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::prelude::*;
|
||||
use tokio::timer::Interval;
|
||||
|
||||
let task = Interval::new(Instant::now(), Duration::from_secs(60))
|
||||
.from_err()
|
||||
.for_each(move |_| db.send(db::RefreshSearchView).flatten())
|
||||
.map_err(|err| error!("Error while updating search view: {}", err));
|
||||
|
||||
thread::spawn(|| tokio::run(task));
|
||||
}
|
||||
|
||||
fn start_oidc_executor(base_url: &str) -> Addr<OidcExecutor> {
|
||||
info!("Initialising OIDC integration ...");
|
||||
let oidc_url = config("OIDC_DISCOVERY_URL");
|
||||
let oidc_config =
|
||||
oidc::load_oidc(&oidc_url).expect("Failed to retrieve OIDC discovery document");
|
||||
|
||||
let oidc = oidc::OidcExecutor {
|
||||
oidc_config,
|
||||
client_id: config("OIDC_CLIENT_ID"),
|
||||
client_secret: config("OIDC_CLIENT_SECRET"),
|
||||
redirect_uri: format!("{}/oidc/callback", base_url),
|
||||
};
|
||||
|
||||
oidc.start()
|
||||
}
|
||||
|
||||
fn start_renderer() -> Addr<Renderer> {
|
||||
let comrak = comrak::ComrakOptions {
|
||||
github_pre_lang: true,
|
||||
ext_strikethrough: true,
|
||||
ext_table: true,
|
||||
ext_autolink: true,
|
||||
ext_tasklist: true,
|
||||
ext_footnotes: true,
|
||||
ext_tagfilter: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
Renderer { comrak }.start()
|
||||
}
|
||||
|
||||
fn gen_session_key() -> [u8; 64] {
|
||||
let mut key_bytes = [0; 64];
|
||||
let mut rng = OsRng::new().expect("Failed to retrieve RNG for key generation");
|
||||
rng.fill_bytes(&mut key_bytes);
|
||||
|
||||
key_bytes
|
||||
}
|
||||
|
||||
fn start_http_server(
|
||||
base_url: String,
|
||||
db_addr: Addr<DbExecutor>,
|
||||
oidc_addr: Addr<OidcExecutor>,
|
||||
renderer_addr: Addr<Renderer>,
|
||||
) {
|
||||
info!("Initialising HTTP server ...");
|
||||
let bind_host = config_default("CONVERSE_BIND_HOST", "127.0.0.1:4567");
|
||||
let key = gen_session_key();
|
||||
let require_login = config_default("REQUIRE_LOGIN", "true".into()) == "true";
|
||||
|
||||
server::new(move || {
|
||||
let state = AppState {
|
||||
db: db_addr.clone(),
|
||||
oidc: oidc_addr.clone(),
|
||||
renderer: renderer_addr.clone(),
|
||||
};
|
||||
|
||||
let identity = IdentityService::new(
|
||||
CookieIdentityPolicy::new(&key)
|
||||
.name("converse_auth")
|
||||
.path("/")
|
||||
.secure(base_url.starts_with("https")),
|
||||
);
|
||||
|
||||
let app = App::with_state(state)
|
||||
.middleware(Logger::default())
|
||||
.middleware(identity)
|
||||
.resource("/", |r| r.method(Method::GET).with(forum_index))
|
||||
.resource("/thread/new", |r| r.method(Method::GET).with(new_thread))
|
||||
.resource("/thread/submit", |r| {
|
||||
r.method(Method::POST).with(submit_thread)
|
||||
})
|
||||
.resource("/thread/reply", |r| {
|
||||
r.method(Method::POST).with(reply_thread)
|
||||
})
|
||||
.resource("/thread/{id}", |r| r.method(Method::GET).with(forum_thread))
|
||||
.resource("/post/{id}/edit", |r| r.method(Method::GET).with(edit_form))
|
||||
.resource("/post/edit", |r| r.method(Method::POST).with(edit_post))
|
||||
.resource("/search", |r| r.method(Method::GET).with(search_forum))
|
||||
.resource("/oidc/login", |r| r.method(Method::GET).with(login))
|
||||
.resource("/oidc/callback", |r| r.method(Method::POST).with(callback))
|
||||
.static_file(
|
||||
"/static/highlight.css",
|
||||
include_bytes!("../static/highlight.css"),
|
||||
)
|
||||
.static_file(
|
||||
"/static/highlight.js",
|
||||
include_bytes!("../static/highlight.js"),
|
||||
)
|
||||
.static_file("/static/styles.css", include_bytes!("../static/styles.css"));
|
||||
|
||||
if require_login {
|
||||
app.middleware(RequireLogin)
|
||||
} else {
|
||||
app
|
||||
}
|
||||
})
|
||||
.bind(&bind_host)
|
||||
.expect(&format!("Could not bind on '{}'", bind_host))
|
||||
.start();
|
||||
}
|
||||
|
||||
fn main() {
|
||||
env_logger::init();
|
||||
|
||||
info!("Welcome to Converse! Hold on tight while we're getting ready.");
|
||||
let sys = actix::System::new("converse");
|
||||
|
||||
let base_url = config("BASE_URL");
|
||||
|
||||
let db_addr = start_db_executor();
|
||||
let oidc_addr = start_oidc_executor(&base_url);
|
||||
let renderer_addr = start_renderer();
|
||||
|
||||
schedule_search_refresh(db_addr.clone());
|
||||
|
||||
start_http_server(base_url, db_addr, oidc_addr, renderer_addr);
|
||||
|
||||
sys.run();
|
||||
}
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
// Copyright (C) 2018-2021 Vincent Ambo <tazjin@tvl.su>
|
||||
//
|
||||
// This file is part of Converse.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or
|
||||
// modify it under the terms of the GNU General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful, but
|
||||
// WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
// General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see
|
||||
// <https://www.gnu.org/licenses/>.
|
||||
|
||||
use crate::schema::{posts, simple_posts, threads, users};
|
||||
use chrono::prelude::{DateTime, Utc};
|
||||
use diesel::sql_types::{Integer, Text};
|
||||
|
||||
/// Represents a single user in the Converse database. Converse does
|
||||
/// not handle logins itself, but rather looks them up based on the
|
||||
/// email address received from an OIDC provider.
|
||||
#[derive(Identifiable, Queryable, Serialize)]
|
||||
pub struct User {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub email: String,
|
||||
pub admin: bool,
|
||||
}
|
||||
|
||||
#[derive(Identifiable, Queryable, Serialize, Associations)]
|
||||
#[belongs_to(User)]
|
||||
pub struct Thread {
|
||||
pub id: i32,
|
||||
pub title: String,
|
||||
pub posted: DateTime<Utc>,
|
||||
pub sticky: bool,
|
||||
pub user_id: i32,
|
||||
pub closed: bool,
|
||||
}
|
||||
|
||||
#[derive(Identifiable, Queryable, Serialize, Associations)]
|
||||
#[belongs_to(Thread)]
|
||||
#[belongs_to(User)]
|
||||
pub struct Post {
|
||||
pub id: i32,
|
||||
pub thread_id: i32,
|
||||
pub body: String,
|
||||
pub posted: DateTime<Utc>,
|
||||
pub user_id: i32,
|
||||
}
|
||||
|
||||
/// This struct is used as the query result type for the simplified
|
||||
/// post view, which already joins user information in the database.
|
||||
#[derive(Identifiable, Queryable, Serialize, Associations)]
|
||||
#[belongs_to(Thread)]
|
||||
pub struct SimplePost {
|
||||
pub id: i32,
|
||||
pub thread_id: i32,
|
||||
pub body: String,
|
||||
pub posted: DateTime<Utc>,
|
||||
pub user_id: i32,
|
||||
pub closed: bool,
|
||||
pub author_name: String,
|
||||
pub author_email: String,
|
||||
}
|
||||
|
||||
/// This struct is used as the query result type for the thread index
|
||||
/// view, which lists the index of threads ordered by the last post in
|
||||
/// each thread.
|
||||
#[derive(Queryable, Serialize)]
|
||||
pub struct ThreadIndex {
|
||||
pub thread_id: i32,
|
||||
pub title: String,
|
||||
pub thread_author: String,
|
||||
pub created: DateTime<Utc>,
|
||||
pub sticky: bool,
|
||||
pub closed: bool,
|
||||
pub post_id: i32,
|
||||
pub post_author: String,
|
||||
pub posted: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Insertable)]
|
||||
#[table_name = "threads"]
|
||||
pub struct NewThread {
|
||||
pub title: String,
|
||||
pub user_id: i32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Insertable)]
|
||||
#[table_name = "users"]
|
||||
pub struct NewUser {
|
||||
pub email: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Insertable)]
|
||||
#[table_name = "posts"]
|
||||
pub struct NewPost {
|
||||
pub thread_id: i32,
|
||||
pub body: String,
|
||||
pub user_id: i32,
|
||||
}
|
||||
|
||||
/// This struct models the response of a full-text search query. It
|
||||
/// does not use a table/schema definition struct like the other
|
||||
/// tables, as no table of this type actually exists.
|
||||
#[derive(QueryableByName, Debug, Serialize)]
|
||||
pub struct SearchResult {
|
||||
#[sql_type = "Integer"]
|
||||
pub post_id: i32,
|
||||
#[sql_type = "Integer"]
|
||||
pub thread_id: i32,
|
||||
#[sql_type = "Text"]
|
||||
pub author: String,
|
||||
#[sql_type = "Text"]
|
||||
pub title: String,
|
||||
|
||||
/// Headline represents the result of Postgres' ts_headline()
|
||||
/// function, which highlights search terms in the search results.
|
||||
#[sql_type = "Text"]
|
||||
pub headline: String,
|
||||
}
|
||||
|
|
@ -1,170 +0,0 @@
|
|||
// Copyright (C) 2018-2021 Vincent Ambo <tazjin@tvl.su>
|
||||
//
|
||||
// This file is part of Converse.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or
|
||||
// modify it under the terms of the GNU General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful, but
|
||||
// WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
// General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see
|
||||
// <https://www.gnu.org/licenses/>.
|
||||
|
||||
//! This module provides authentication via OIDC compliant
|
||||
//! authentication sources.
|
||||
//!
|
||||
//! Currently Converse only supports a single OIDC provider. Note that
|
||||
//! this has so far only been tested with Office365.
|
||||
|
||||
use crate::errors::*;
|
||||
use actix::prelude::*;
|
||||
use crimp::Request;
|
||||
use curl::easy::Form;
|
||||
use url::Url;
|
||||
use url_serde;
|
||||
|
||||
/// This structure represents the contents of an OIDC discovery
|
||||
/// document.
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub struct OidcConfig {
|
||||
#[serde(with = "url_serde")]
|
||||
authorization_endpoint: Url,
|
||||
token_endpoint: String,
|
||||
userinfo_endpoint: String,
|
||||
|
||||
scopes_supported: Vec<String>,
|
||||
issuer: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct OidcExecutor {
|
||||
pub client_id: String,
|
||||
pub client_secret: String,
|
||||
pub redirect_uri: String,
|
||||
pub oidc_config: OidcConfig,
|
||||
}
|
||||
|
||||
/// This struct represents the form response returned by an OIDC
|
||||
/// provider with the `code`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CodeResponse {
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
/// This struct represents the data extracted from the ID token and
|
||||
/// stored in the user's session.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Author {
|
||||
pub name: String,
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
impl Actor for OidcExecutor {
|
||||
type Context = Context<Self>;
|
||||
}
|
||||
|
||||
/// Message used to request the login URL:
|
||||
pub struct GetLoginUrl; // TODO: Add a nonce parameter stored in session.
|
||||
message!(GetLoginUrl, String);
|
||||
|
||||
impl Handler<GetLoginUrl> for OidcExecutor {
|
||||
type Result = String;
|
||||
|
||||
fn handle(&mut self, _: GetLoginUrl, _: &mut Self::Context) -> Self::Result {
|
||||
let mut url: Url = self.oidc_config.authorization_endpoint.clone();
|
||||
{
|
||||
let mut params = url.query_pairs_mut();
|
||||
params.append_pair("client_id", &self.client_id);
|
||||
params.append_pair("response_type", "code");
|
||||
params.append_pair("scope", "openid");
|
||||
params.append_pair("redirect_uri", &self.redirect_uri);
|
||||
params.append_pair("response_mode", "form_post");
|
||||
}
|
||||
return url.into_string();
|
||||
}
|
||||
}
|
||||
|
||||
/// Message used to request the token from the returned code and
|
||||
/// retrieve userinfo from the appropriate endpoint.
|
||||
pub struct RetrieveToken(pub CodeResponse);
|
||||
message!(RetrieveToken, Result<Author>);
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TokenResponse {
|
||||
access_token: String,
|
||||
}
|
||||
|
||||
// TODO: This is currently hardcoded to Office365 fields.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Userinfo {
|
||||
name: String,
|
||||
unique_name: String, // email in office365
|
||||
}
|
||||
|
||||
impl Handler<RetrieveToken> for OidcExecutor {
|
||||
type Result = Result<Author>;
|
||||
|
||||
fn handle(&mut self, msg: RetrieveToken, _: &mut Self::Context) -> Self::Result {
|
||||
debug!("Received OAuth2 code, requesting access_token");
|
||||
|
||||
let mut form = Form::new();
|
||||
form.part("client_id")
|
||||
.contents(&self.client_id.as_bytes())
|
||||
.add()
|
||||
.expect("critical error: invalid form data");
|
||||
|
||||
form.part("client_secret")
|
||||
.contents(&self.client_secret.as_bytes())
|
||||
.add()
|
||||
.expect("critical error: invalid form data");
|
||||
|
||||
form.part("grant_type")
|
||||
.contents("authorization_code".as_bytes())
|
||||
.add()
|
||||
.expect("critical error: invalid form data");
|
||||
|
||||
form.part("code")
|
||||
.contents(&msg.0.code.as_bytes())
|
||||
.add()
|
||||
.expect("critical error: invalid form data");
|
||||
|
||||
form.part("redirect_uri")
|
||||
.contents(&self.redirect_uri.as_bytes())
|
||||
.add()
|
||||
.expect("critical error: invalid form data");
|
||||
|
||||
let response = Request::post(&self.oidc_config.token_endpoint)
|
||||
.user_agent(concat!("converse-", env!("CARGO_PKG_VERSION")))?
|
||||
.form(form)
|
||||
.send()?;
|
||||
|
||||
debug!("Received token response: {:?}", response);
|
||||
let token: TokenResponse = response.as_json()?.body;
|
||||
|
||||
let bearer = format!("Bearer {}", token.access_token);
|
||||
let user: Userinfo = Request::get(&self.oidc_config.userinfo_endpoint)
|
||||
.user_agent(concat!("converse-", env!("CARGO_PKG_VERSION")))?
|
||||
.header("Authorization", &bearer)?
|
||||
.send()?
|
||||
.as_json()?
|
||||
.body;
|
||||
|
||||
Ok(Author {
|
||||
name: user.name,
|
||||
email: user.unique_name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience function to attempt loading an OIDC discovery document
|
||||
/// from a specified URL:
|
||||
pub fn load_oidc(url: &str) -> Result<OidcConfig> {
|
||||
let config: OidcConfig = Request::get(url).send()?.as_json()?.body;
|
||||
Ok(config)
|
||||
}
|
||||
|
|
@ -1,245 +0,0 @@
|
|||
// Copyright (C) 2018-2021 Vincent Ambo <tazjin@tvl.su>
|
||||
//
|
||||
// This file is part of Converse.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or
|
||||
// modify it under the terms of the GNU General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful, but
|
||||
// WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
// General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see
|
||||
// <https://www.gnu.org/licenses/>.
|
||||
|
||||
//! This module defines a rendering actor used for processing Converse
|
||||
//! data into whatever format is needed by the templates and rendering
|
||||
//! them.
|
||||
|
||||
use crate::errors::*;
|
||||
use crate::models::*;
|
||||
use actix::prelude::*;
|
||||
use askama::Template;
|
||||
use chrono::prelude::{DateTime, Utc};
|
||||
use comrak::{markdown_to_html, ComrakOptions};
|
||||
use md5;
|
||||
use std::fmt;
|
||||
|
||||
pub struct Renderer {
|
||||
pub comrak: ComrakOptions,
|
||||
}
|
||||
|
||||
impl Actor for Renderer {
|
||||
type Context = actix::Context<Self>;
|
||||
}
|
||||
|
||||
/// Represents a data formatted for human consumption
|
||||
#[derive(Debug)]
|
||||
struct FormattedDate(DateTime<Utc>);
|
||||
|
||||
impl fmt::Display for FormattedDate {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
write!(f, "{}", self.0.format("%a %d %B %Y, %R"))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct IndexThread {
|
||||
id: i32,
|
||||
title: String,
|
||||
sticky: bool,
|
||||
closed: bool,
|
||||
posted: FormattedDate,
|
||||
author_name: String,
|
||||
post_author: String,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "index.html")]
|
||||
struct IndexPageTemplate {
|
||||
threads: Vec<IndexThread>,
|
||||
}
|
||||
|
||||
// "Renderable" structures with data transformations applied.
|
||||
#[derive(Debug)]
|
||||
struct RenderablePost {
|
||||
id: i32,
|
||||
body: String,
|
||||
posted: FormattedDate,
|
||||
author_name: String,
|
||||
author_gravatar: String,
|
||||
editable: bool,
|
||||
}
|
||||
|
||||
/// This structure represents the transformed thread data with
|
||||
/// Markdown rendering and other changes applied.
|
||||
#[derive(Template)]
|
||||
#[template(path = "thread.html")]
|
||||
struct RenderableThreadPage {
|
||||
id: i32,
|
||||
title: String,
|
||||
closed: bool,
|
||||
posts: Vec<RenderablePost>,
|
||||
}
|
||||
|
||||
/// Helper function for computing Gravatar links.
|
||||
fn md5_hex(input: &[u8]) -> String {
|
||||
format!("{:x}", md5::compute(input))
|
||||
}
|
||||
|
||||
/// The different types of editing modes supported by the editing
|
||||
/// template:
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum EditingMode {
|
||||
NewThread,
|
||||
PostReply,
|
||||
EditPost,
|
||||
}
|
||||
|
||||
impl Default for EditingMode {
|
||||
fn default() -> EditingMode {
|
||||
EditingMode::NewThread
|
||||
}
|
||||
}
|
||||
|
||||
/// This is the template used for rendering the new thread, edit post
|
||||
/// and reply to thread forms.
|
||||
#[derive(Template, Default)]
|
||||
#[template(path = "post.html")]
|
||||
pub struct FormTemplate {
|
||||
/// Which editing mode is to be used by the template?
|
||||
pub mode: EditingMode,
|
||||
|
||||
/// Potential alerts to display to the user (e.g. input validation
|
||||
/// results)
|
||||
pub alerts: Vec<&'static str>,
|
||||
|
||||
/// Either the title to be used in the subject field or the title
|
||||
/// of the thread the user is responding to.
|
||||
pub title: Option<String>,
|
||||
|
||||
/// Body of the post being edited, if present.
|
||||
pub post: Option<String>,
|
||||
|
||||
/// ID of the thread being replied to or the post being edited.
|
||||
pub id: Option<i32>,
|
||||
}
|
||||
|
||||
/// Message used to render new thread page.
|
||||
///
|
||||
/// It can optionally contain a vector of warnings to display to the
|
||||
/// user in alert boxes, such as input validation errors.
|
||||
#[derive(Default)]
|
||||
pub struct NewThreadPage {
|
||||
pub alerts: Vec<&'static str>,
|
||||
pub title: Option<String>,
|
||||
pub post: Option<String>,
|
||||
}
|
||||
message!(NewThreadPage, Result<String>);
|
||||
|
||||
impl Handler<NewThreadPage> for Renderer {
|
||||
type Result = Result<String>;
|
||||
|
||||
fn handle(&mut self, msg: NewThreadPage, _: &mut Self::Context) -> Self::Result {
|
||||
let ctx = FormTemplate {
|
||||
alerts: msg.alerts,
|
||||
title: msg.title,
|
||||
post: msg.post,
|
||||
..Default::default()
|
||||
};
|
||||
ctx.render().map_err(|e| e.into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Message used to render post editing page.
|
||||
pub struct EditPostPage {
|
||||
pub id: i32,
|
||||
pub post: String,
|
||||
}
|
||||
message!(EditPostPage, Result<String>);
|
||||
|
||||
impl Handler<EditPostPage> for Renderer {
|
||||
type Result = Result<String>;
|
||||
|
||||
fn handle(&mut self, msg: EditPostPage, _: &mut Self::Context) -> Self::Result {
|
||||
let ctx = FormTemplate {
|
||||
mode: EditingMode::EditPost,
|
||||
id: Some(msg.id),
|
||||
post: Some(msg.post),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
ctx.render().map_err(|e| e.into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Message used to render search results
|
||||
#[derive(Template)]
|
||||
#[template(path = "search.html")]
|
||||
pub struct SearchResultPage {
|
||||
pub query: String,
|
||||
pub results: Vec<SearchResult>,
|
||||
}
|
||||
message!(SearchResultPage, Result<String>);
|
||||
|
||||
impl Handler<SearchResultPage> for Renderer {
|
||||
type Result = Result<String>;
|
||||
|
||||
fn handle(&mut self, msg: SearchResultPage, _: &mut Self::Context) -> Self::Result {
|
||||
msg.render().map_err(|e| e.into())
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: actor-free implementation below
|
||||
|
||||
/// Render the index page for the given thread list.
|
||||
pub fn index_page(threads: Vec<ThreadIndex>) -> Result<String> {
|
||||
let threads: Vec<IndexThread> = threads
|
||||
.into_iter()
|
||||
.map(|thread| IndexThread {
|
||||
id: thread.thread_id,
|
||||
title: thread.title, // escape_html(&thread.title),
|
||||
sticky: thread.sticky,
|
||||
closed: thread.closed,
|
||||
posted: FormattedDate(thread.posted),
|
||||
author_name: thread.thread_author,
|
||||
post_author: thread.post_author,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let tpl = IndexPageTemplate { threads };
|
||||
tpl.render().map_err(|e| e.into())
|
||||
}
|
||||
|
||||
// Render the page of a given thread.
|
||||
pub fn thread_page(user: i32, thread: Thread, posts: Vec<SimplePost>) -> Result<String> {
|
||||
let posts = posts
|
||||
.into_iter()
|
||||
.map(|post| {
|
||||
let editable = user != 1 && post.user_id == user;
|
||||
|
||||
let comrak = ComrakOptions::default(); // TODO(tazjin): cheddar
|
||||
RenderablePost {
|
||||
id: post.id,
|
||||
body: markdown_to_html(&post.body, &comrak),
|
||||
posted: FormattedDate(post.posted),
|
||||
author_name: post.author_name.clone(),
|
||||
author_gravatar: md5_hex(post.author_email.as_bytes()),
|
||||
editable,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let renderable = RenderableThreadPage {
|
||||
posts,
|
||||
closed: thread.closed,
|
||||
id: thread.id,
|
||||
title: thread.title,
|
||||
};
|
||||
|
||||
Ok(renderable.render()?)
|
||||
}
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
// Copyright (C) 2018-2021 Vincent Ambo <tazjin@tvl.su>
|
||||
//
|
||||
// This file is part of Converse.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or
|
||||
// modify it under the terms of the GNU General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful, but
|
||||
// WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
// General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see
|
||||
// <https://www.gnu.org/licenses/>.
|
||||
|
||||
table! {
|
||||
posts (id) {
|
||||
id -> Int4,
|
||||
thread_id -> Int4,
|
||||
body -> Text,
|
||||
posted -> Timestamptz,
|
||||
user_id -> Int4,
|
||||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
threads (id) {
|
||||
id -> Int4,
|
||||
title -> Varchar,
|
||||
posted -> Timestamptz,
|
||||
sticky -> Bool,
|
||||
user_id -> Int4,
|
||||
closed -> Bool,
|
||||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
users (id) {
|
||||
id -> Int4,
|
||||
email -> Varchar,
|
||||
name -> Varchar,
|
||||
admin -> Bool,
|
||||
}
|
||||
}
|
||||
|
||||
// Note: Manually inserted as print-schema does not add views.
|
||||
table! {
|
||||
simple_posts (id) {
|
||||
id -> Int4,
|
||||
thread_id -> Int4,
|
||||
body -> Text,
|
||||
posted -> Timestamptz,
|
||||
user_id -> Int4,
|
||||
closed -> Bool,
|
||||
author_name -> Text,
|
||||
author_email -> Text,
|
||||
}
|
||||
}
|
||||
|
||||
// Note: Manually inserted as print-schema does not add views.
|
||||
table! {
|
||||
thread_index (thread_id) {
|
||||
thread_id -> Int4,
|
||||
title -> Text,
|
||||
thread_author -> Text,
|
||||
created -> Timestamptz,
|
||||
sticky -> Bool,
|
||||
closed -> Bool,
|
||||
post_id -> Int4,
|
||||
post_author -> Text,
|
||||
posted -> Timestamptz,
|
||||
}
|
||||
}
|
||||
|
||||
joinable!(posts -> threads (thread_id));
|
||||
joinable!(posts -> users (user_id));
|
||||
joinable!(threads -> users (user_id));
|
||||
joinable!(simple_posts -> threads (thread_id));
|
||||
|
||||
allow_tables_to_appear_in_same_query!(posts, threads, users, simple_posts,);
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
/*
|
||||
|
||||
github.com style (c) Vasily Polovnyov <vast@whiteants.net>
|
||||
|
||||
*/
|
||||
|
||||
.hljs {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
padding: 0.5em;
|
||||
color: #333;
|
||||
background: #f8f8f8;
|
||||
}
|
||||
|
||||
.hljs-comment,
|
||||
.hljs-quote {
|
||||
color: #998;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.hljs-keyword,
|
||||
.hljs-selector-tag,
|
||||
.hljs-subst {
|
||||
color: #333;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.hljs-number,
|
||||
.hljs-literal,
|
||||
.hljs-variable,
|
||||
.hljs-template-variable,
|
||||
.hljs-tag .hljs-attr {
|
||||
color: #008080;
|
||||
}
|
||||
|
||||
.hljs-string,
|
||||
.hljs-doctag {
|
||||
color: #d14;
|
||||
}
|
||||
|
||||
.hljs-title,
|
||||
.hljs-section,
|
||||
.hljs-selector-id {
|
||||
color: #900;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.hljs-subst {
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.hljs-type,
|
||||
.hljs-class .hljs-title {
|
||||
color: #458;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.hljs-tag,
|
||||
.hljs-name,
|
||||
.hljs-attribute {
|
||||
color: #000080;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.hljs-regexp,
|
||||
.hljs-link {
|
||||
color: #009926;
|
||||
}
|
||||
|
||||
.hljs-symbol,
|
||||
.hljs-bullet {
|
||||
color: #990073;
|
||||
}
|
||||
|
||||
.hljs-built_in,
|
||||
.hljs-builtin-name {
|
||||
color: #0086b3;
|
||||
}
|
||||
|
||||
.hljs-meta {
|
||||
color: #999;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.hljs-deletion {
|
||||
background: #fdd;
|
||||
}
|
||||
|
||||
.hljs-addition {
|
||||
background: #dfd;
|
||||
}
|
||||
|
||||
.hljs-emphasis {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.hljs-strong {
|
||||
font-weight: bold;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -1,145 +0,0 @@
|
|||
* :not(.material-icons) {
|
||||
font-family: 'Ubuntu', sans-serif;
|
||||
}
|
||||
|
||||
code, pre, code * {
|
||||
font-family: 'Ubuntu Mono', monospace !important;
|
||||
}
|
||||
|
||||
.cvs-title, .thread-link {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.thread-list-item:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.thread-link {
|
||||
padding: 5px;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.thread-title {
|
||||
padding-right: 15vw;
|
||||
}
|
||||
|
||||
.search-field {
|
||||
margin-right: 15px;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.thread-author {
|
||||
font-style: italic;
|
||||
font-size: 85%;
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 768px) {
|
||||
.converse main {
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.mdl-list__item-text-body {
|
||||
max-height: 40px;
|
||||
}
|
||||
|
||||
.thread-divider:after {
|
||||
border-bottom: 1px solid rgba(0,0,0,.13);
|
||||
content:"";
|
||||
position: absolute;
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.converse .mdl-layout__header-row {
|
||||
padding-left: 40px;
|
||||
}
|
||||
.converse .mdl-layout.is-small-screen .mdl-layout__header-row h3 {
|
||||
font-size: inherit;
|
||||
}
|
||||
.converse .mdl-card {
|
||||
height: auto;
|
||||
display: -webkit-flex;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
-webkit-flex-direction: column;
|
||||
-ms-flex-direction: column;
|
||||
flex-direction: column;
|
||||
}
|
||||
.converse .mdl-card > * {
|
||||
height: auto;
|
||||
}
|
||||
.converse .mdl-card .mdl-card__supporting-text {
|
||||
margin: 40px;
|
||||
-webkit-flex-grow: 1;
|
||||
-ms-flex-positive: 1;
|
||||
flex-grow: 1;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
width: calc(100% - 80px);
|
||||
}
|
||||
.mdl-demo.converse .mdl-card__supporting-text h4 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.converse .mdl-card__actions {
|
||||
margin: 0;
|
||||
padding: 4px 40px;
|
||||
color: inherit;
|
||||
}
|
||||
.converse section.section--center {
|
||||
max-width: 860px;
|
||||
}
|
||||
.converse .mdl-card .avatar-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
margin-top: 30px;
|
||||
}
|
||||
.desktop-avatar {
|
||||
width: 80px;
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
.mobile-avatar {
|
||||
width: 30px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.mobile-date {
|
||||
text-decoration: none;
|
||||
}
|
||||
.converse .mdl-card .post-box {
|
||||
margin: 20px;
|
||||
}
|
||||
.converse .mdl-card .post-actions {
|
||||
display: flex;
|
||||
padding-right: 5px;
|
||||
}
|
||||
.post-action {
|
||||
margin: 5px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.converse section.post-section {
|
||||
padding: 5px;
|
||||
}
|
||||
.post-date {
|
||||
text-decoration: none;
|
||||
font-size: 80%;
|
||||
}
|
||||
.mdl-layout__content {
|
||||
flex: 1 0 auto;
|
||||
}
|
||||
.converse .reply-box {
|
||||
padding-top: 10px;
|
||||
}
|
||||
.search-result {
|
||||
margin: 8px;
|
||||
}
|
||||
.search-result .mdl-button {
|
||||
margin: 3px;
|
||||
}
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0">
|
||||
<title>Converse: Index</title>
|
||||
|
||||
<!-- TODO -->
|
||||
<meta http-equiv="Content-Security-Policy" content="script-src https://code.getmdl.io 'self';">
|
||||
<!-- <link rel="shortcut icon" href="images/favicon.png"> -->
|
||||
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Ubuntu">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
|
||||
<link rel="stylesheet" href="https://code.getmdl.io/1.3.0/material.blue_grey-orange.min.css" />
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
</head>
|
||||
<body class="converse mdl-base mdl-color-text--grey-700 mdl-color--grey-100">
|
||||
<div class="mdl-layout mdl-layout--fixed-header mdl-js-layout mdl-color--grey-100">
|
||||
<header class="mdl-layout__header mdl-layout__header--scroll mdl-color--primary-dark">
|
||||
<div class="mdl-layout__header-row">
|
||||
<a href="/" class="mdl-layout-title mdl-color-text--blue-grey-50 cvs-title">Converse</a>
|
||||
<div class="mdl-layout-spacer"></div>
|
||||
<div class="mdl-textfield mdl-js-textfield mdl-textfield--floating-label mdl-color-text--blue-grey-50 search-field">
|
||||
<form method="get" action="/search">
|
||||
<input class="mdl-textfield__input" type="search" id="search-query" aria-label="Search" name="query">
|
||||
<label class="mdl-textfield__label mdl-color-text--blue-grey-100" for="search-query">Search query...</label>
|
||||
<input type="submit" hidden />
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<a href="/thread/new">
|
||||
<button class="mdl-button mdl-js-button mdl-button--raised mdl-button--accent mdl-js-ripple-effect">
|
||||
New Thread
|
||||
</button>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
<main class="mdl-layout__content">
|
||||
<section class="section--center mdl-grid mdl-grid--no-spacing mdl-shadow--2dp">
|
||||
<div class="mdl-card mdl-cell mdl-cell--12-col">
|
||||
<div class="mdl-card__supporting-text mdl-grid">
|
||||
<h4 class="mdl-cell mdl-cell--12-col">Latest threads:</h4>
|
||||
<ul class="mdl-list">
|
||||
{% for thread in threads %}
|
||||
<li class="mdl-list__item thread-list-item mdl-list__item--three-line">
|
||||
<a class="thread-link mdl-color-text--grey-800" href="/thread/{{ thread.id }}">
|
||||
<span class="mdl-list__item-primary-content {% if loop.index < threads.len() %}thread-divider{% endif %}">
|
||||
<button class="mdl-button mdl-js-button mdl-button--fab mdl-button--mini-fab mdl-button--colored mdl-list__item-icon">
|
||||
<i class="material-icons">
|
||||
{% if thread.sticky %}
|
||||
announcement
|
||||
{% else if thread.closed %}
|
||||
lock
|
||||
{% else %}
|
||||
library_books
|
||||
{% endif %}
|
||||
</i>
|
||||
</button>
|
||||
<span class="thread-title">{{ thread.title }}<span class="thread-author"> by {{ thread.author_name }}</span></span>
|
||||
<span class="mdl-list__item-text-body">
|
||||
Last reply by {{ thread.post_author }} on {{ thread.posted }}.
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<footer class="mdl-mini-footer">
|
||||
<div class="mdl-mini-footer--right-section">
|
||||
<p>Powered by <a href="https://code.tvl.fyi/about/web/converse">Converse</a></p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
<script src="https://code.getmdl.io/1.3.0/material.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
{#
|
||||
This template is shared by the new thread, reply and post-editing pages.
|
||||
|
||||
The main display differences between the different editing styles are the
|
||||
headline of the page ("Submit new thread", "Reply to thread", "Edit post")
|
||||
and whether or not the subject line field is displayed in the input form.
|
||||
|
||||
Every one of these pages can have a variable length list of alerts submitted
|
||||
into the template, which will be rendered as Boostrap alert boxes above the
|
||||
user input form.
|
||||
#}
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0">
|
||||
<title>Converse: Post</title>
|
||||
|
||||
<meta http-equiv="Content-Security-Policy" content="script-src https://code.getmdl.io 'self';">
|
||||
<!-- <link rel="shortcut icon" href="images/favicon.png"> -->
|
||||
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Ubuntu">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
|
||||
<link rel="stylesheet" href="https://code.getmdl.io/1.3.0/material.blue_grey-orange.min.css" />
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
</head>
|
||||
<body class="converse mdl-base mdl-color-text--grey-700 mdl-color--grey-100">
|
||||
<div class="mdl-layout mdl-layout--fixed-header mdl-js-layout mdl-color--grey-100">
|
||||
<header class="mdl-layout__header mdl-layout__header--scroll mdl-color--primary-dark">
|
||||
<div class="mdl-layout__header-row">
|
||||
<a href="/" class="mdl-layout-title mdl-color-text--blue-grey-50 cvs-title">
|
||||
{% match mode %}
|
||||
{% when EditingMode::NewThread %}
|
||||
Converse: Submit new thread
|
||||
{% when EditingMode::PostReply %}
|
||||
Converse: Reply to thread
|
||||
{% when EditingMode::EditPost %}
|
||||
Converse: Edit post
|
||||
{% endmatch %}
|
||||
</a>
|
||||
<div class="mdl-layout-spacer"></div>
|
||||
<a href="/">
|
||||
<button class="mdl-button mdl-js-button mdl-button--raised mdl-button--accent mdl-js-ripple-effect">
|
||||
Back to index
|
||||
</button>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
<main class="mdl-layout__content mdl-grid">
|
||||
<div class="mdl-card mdl-shadow--2dp mdl-cell--8-col">
|
||||
{% match mode %}
|
||||
{% when EditingMode::NewThread %}
|
||||
<form action="/thread/submit" method="post">
|
||||
{% when EditingMode::PostReply %}
|
||||
<form action="/thread/reply" method="post">
|
||||
{% when EditingMode::EditPost %}
|
||||
<form action="/post/edit" method="post">
|
||||
{% endmatch %}
|
||||
{% match mode %}
|
||||
{% when EditingMode::PostReply %}
|
||||
<input type="hidden" id="thread_id" name="thread_id" value="{{ id.unwrap() }}">
|
||||
{% when EditingMode::EditPost %}
|
||||
<input type="hidden" id="thread_id" name="post_id" value="{{ id.unwrap() }}">
|
||||
{% else %}
|
||||
{# no post ID when making a new thread #}
|
||||
{% endmatch %}
|
||||
<div class="mdl-card__supporting-text">
|
||||
{% for alert in alerts %}
|
||||
<span class="mdl-chip mdl-color--red-200">
|
||||
<span class="mdl-chip__text">{{ alert }} </span>
|
||||
</span>
|
||||
{% endfor %}
|
||||
{% if mode == EditingMode::NewThread %}
|
||||
<div class="mdl-textfield mdl-js-textfield mdl-textfield--floating-label mdl-cell--12-col">
|
||||
<input class="mdl-textfield__input" type="text" id="title" name="title" aria-label="thread title" required {% match title %}{% when Some with (title_text) %}value="{{ title_text }}"{% else %}{# Nothing! #}{% endmatch %}>
|
||||
<label class="mdl-textfield__label" for="title">Thread title</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="mdl-textfield mdl-js-textfield mdl-textfield--floating-label mdl-cell--12-col">
|
||||
<textarea class="mdl-textfield__input" type="text" rows="25" id="post" name="post" aria-label="post content" required>
|
||||
{%- match post -%}
|
||||
{%- when Some with (post_text) -%}
|
||||
{{- post_text -}}
|
||||
{%- else -%}
|
||||
{# Nothing! #}
|
||||
{%- endmatch -%}
|
||||
</textarea>
|
||||
<label class="mdl-textfield__label" for="body">Content ...</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mdl-card__actions">
|
||||
<input class="mdl-button mdl-button--raised mdl-button--colored mdl-js-button mdl-js-ripple-effect" type="submit" value="Submit!">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="mdl-card mdl-shadow--2dp mdl-cell--4-col">
|
||||
<div class="mdl-card__title mdl-card--border">
|
||||
Quick Markdown primer:
|
||||
</div>
|
||||
<div class="mdl-card__supporting-text">
|
||||
<p>
|
||||
Remember that you can use <a href="https://daringfireball.net/projects/markdown/basics"><strong>Markdown</strong></a> when
|
||||
writing your posts:
|
||||
</p>
|
||||
<p><i>*italic text*</i></p>
|
||||
<p><strong>**bold text**</strong></p>
|
||||
<p><s>~strikethrough text~</s></p>
|
||||
<p><code>[link text](https://some.link.com/)</code></p>
|
||||
<p><code></code></p>
|
||||
<p>Use <code>*</code> or <code>-</code> to enumerate lists.</p>
|
||||
<p>See Markdown documentation for more information!</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<footer class="mdl-mini-footer">
|
||||
<div class="mdl-mini-footer--right-section">
|
||||
<p>Powered by <a href="https://code.tvl.fyi/about/web/converse">Converse</a></p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
<script src="https://code.getmdl.io/1.3.0/material.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0">
|
||||
<title>Converse: Search results</title>
|
||||
|
||||
<!-- TODO -->
|
||||
<meta http-equiv="Content-Security-Policy" content="script-src https://code.getmdl.io 'self';">
|
||||
<!-- <link rel="shortcut icon" href="images/favicon.png"> -->
|
||||
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Ubuntu">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
|
||||
<link rel="stylesheet" href="https://code.getmdl.io/1.3.0/material.blue_grey-orange.min.css" />
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
</head>
|
||||
<body class="converse mdl-base mdl-color-text--grey-700 mdl-color--grey-100">
|
||||
<div class="mdl-layout mdl-layout--fixed-header mdl-js-layout mdl-color--grey-100">
|
||||
<header class="mdl-layout__header mdl-layout__header--scroll mdl-color--primary-dark">
|
||||
<div class="mdl-layout__header-row">
|
||||
<a href="/" class="mdl-layout-title mdl-color-text--blue-grey-50 cvs-title">Converse</a>
|
||||
<div class="mdl-layout-spacer"></div>
|
||||
<div class="mdl-textfield mdl-js-textfield mdl-textfield--floating-label mdl-color-text--blue-grey-50 search-field">
|
||||
<form method="get" action="/search">
|
||||
<input class="mdl-textfield__input" type="search" id="search-query" aria-label="Search" name="query">
|
||||
<label class="mdl-textfield__label mdl-color-text--blue-grey-100" for="search-query">Search query...</label>
|
||||
<input type="submit" hidden />
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<a href="/">
|
||||
<button class="mdl-button mdl-js-button mdl-button--raised mdl-button--accent mdl-js-ripple-effect">
|
||||
Back to index
|
||||
</button>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
<main class="mdl-layout__content">
|
||||
<section class="section--center mdl-grid">
|
||||
<div class="mdl-cell--12-col">
|
||||
<h4>Search results for '{{ query }}':</h4>
|
||||
</div>
|
||||
{% for result in results %}
|
||||
<div class="mdl-card mdl-cell--6-col search-result mdl-shadow--2dp">
|
||||
<div class="mdl-card__supporting-text">
|
||||
<p>Posted in '{{ result.title }}' by {{ result.author }}:</p>
|
||||
<p>{{ result.headline|safe }}</p>
|
||||
</div>
|
||||
<div class="mdl-card__actions mdl-card--border post-actions">
|
||||
<div class="mdl-layout-spacer"></div>
|
||||
<a class="mdl-button mdl-js-button mdl-button--fab mdl-button--mini-fab mdl-button--colored" href="/thread/{{ result.thread_id }}#post-{{ result.post_id }}">
|
||||
<i class="material-icons">arrow_forward</i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</section>
|
||||
</main>
|
||||
<footer class="mdl-mini-footer">
|
||||
<div class="mdl-mini-footer--right-section">
|
||||
<p>Powered by <a href="https://code.tvl.fyi/about/web/converse">Converse</a></p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
<script src="https://code.getmdl.io/1.3.0/material.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,111 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0">
|
||||
<title>Converse: {{ title }}</title>
|
||||
|
||||
<!-- TODO -->
|
||||
<meta http-equiv="Content-Security-Policy" content="script-src https://code.getmdl.io 'self';">
|
||||
<!-- <link rel="shortcut icon" href="images/favicon.png"> -->
|
||||
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Ubuntu">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Ubuntu+Mono">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
|
||||
<link rel="stylesheet" href="https://code.getmdl.io/1.3.0/material.blue_grey-orange.min.css" />
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
<!-- Syntax highlighting for code -->
|
||||
<link rel="stylesheet" href="/static/highlight.css">
|
||||
<style>img { max-width:100%; height:auto; }</style>
|
||||
<script src="/static/highlight.js"></script>
|
||||
</head>
|
||||
<body class="converse mdl-base mdl-color-text--grey-700 mdl-color--grey-100">
|
||||
<div class="mdl-layout mdl-layout--fixed-header mdl-js-layout mdl-color--grey-100">
|
||||
<header class="mdl-layout__header mdl-color--primary-dark">
|
||||
<div class="mdl-layout__header-row">
|
||||
<a href="/" class="mdl-layout-title mdl-color-text--blue-grey-50 cvs-title">Converse: {{ title }}</a>
|
||||
<div class="mdl-layout-spacer"></div>
|
||||
<a href="/">
|
||||
<button class="mdl-button mdl-js-button mdl-button--raised mdl-button--accent mdl-js-ripple-effect">
|
||||
Back to index
|
||||
</button>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
<main class="mdl-layout__content">
|
||||
{% for post in posts -%}
|
||||
<section id="post-{{ post.id }}" class="section--center mdl-grid mdl-grid--no-spacing">
|
||||
<!-- card to display avatars on desktop -->
|
||||
<div class="mdl-card mdl-shadow--2dp mdl-cell--2-col mdl-cell--hide-phone mdl-cell--hide-tablet avatar-box">
|
||||
<div class="avatar-card">
|
||||
<img class="desktop-avatar" src="https://www.gravatar.com/avatar/{{ post.author_gravatar }}?d=monsterid&s=160" />
|
||||
<p class="user-name">{{ post.author_name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- card for main post content -->
|
||||
<div class="mdl-card mdl-shadow--2dp post-box mdl-cell--10-col">
|
||||
<!-- card section for displaying user & post information on mobile -->
|
||||
<div class="mdl-card__supporting-text mdl-card--border mdl-cell--hide-desktop mdl-color-text--blue-grey-500 mobile-user">
|
||||
<img class="mobile-avatar" src="https://www.gravatar.com/avatar/{{ post.author_gravatar }}?d=monsterid"/>
|
||||
<span> {{ post.author_name }} posted on </span>
|
||||
<a class="mdl-color-text--blue-grey-500 mobile-date" href="/thread/{{ id }}#post-{{ post.id }}">{{ post.posted }}</a>
|
||||
</div>
|
||||
<!-- card section to display post date on desktop -->
|
||||
<div class="mdl-card__menu mdl-cell--hide-phone mdl-cell--hide-tablet">
|
||||
<a class="post-date mdl-color-text--blue-grey-500" href="/thread/{{ id }}#post-{{ post.id }}">{{ post.posted }}</a>
|
||||
</div>
|
||||
<!-- card section for actual post content -->
|
||||
<div class="mdl-card__supporting-text post-box">{{ post.body|safe }}</div>
|
||||
<!-- card section for post actions -->
|
||||
<div class="mdl-card__actions post-actions">
|
||||
<div class="mdl-layout-spacer"></div>
|
||||
|
||||
{% if post.editable %}
|
||||
<a href="/post/{{ post.id }}/edit" class="mdl-button mdl-js-button mdl-button--accent" id="edit-post-{{ post.id }}" aria-label="Edit post">
|
||||
<i class="material-icons">edit</i>
|
||||
<span class="mdl-tooltip mdl-tooltip--top" for="edit-post-{{ post.id }}">Edit post</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
<button class="mdl-button mdl-js-button mdl-button--accent" id="quote-post-{{ post.id }}" aria-label="Quote post" disabled>
|
||||
<i class="material-icons">reply</i>
|
||||
<span class="mdl-tooltip mdl-tooltip--top" for="quote-post-{{ post.id }}">Quote post</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endfor %}
|
||||
|
||||
<!-- section for writing a response on the same page -->
|
||||
<section id="post-reply" class="section--center mdl-grid mdl-grid--no-spacing reply-box">
|
||||
<div class="mdl-card mdl-shadow--2dp mdl-cell--12-col">
|
||||
{% if closed %}
|
||||
<div class="mdl-card__supporting-text">
|
||||
This thread is <b>closed</b> and can no longer be responded to.
|
||||
</div>
|
||||
{% else %}
|
||||
<form id="reply-form" action="/thread/reply" method="post">
|
||||
<input type="hidden" id="thread_id" name="thread_id" value="{{ id }}">
|
||||
|
||||
<div class="mdl-card__supporting-text">
|
||||
<div class="mdl-textfield mdl-js-textfield mdl-textfield--floating-label mdl-cell--12-col">
|
||||
<textarea class="mdl-textfield__input" type="text" rows="8" id="post" name="post" aria-label="reply content"></textarea>
|
||||
<label class="mdl-textfield__label" for="post">Write a reply</label>
|
||||
</div>
|
||||
<button class="mdl-button mdl-button--raised mdl-button--primary mdl-js-button mdl-js-ripple-effect" type="submit">
|
||||
Post!
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<footer class="mdl-mini-footer">
|
||||
<div class="mdl-mini-footer--right-section">
|
||||
<p>Powered by <a href="https://code.tvl.fyi/about/web/converse">Converse</a></p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
<script src="https://code.getmdl.io/1.3.0/material.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
* DONE Pin *-versions in cargo.toml
|
||||
* DONE Markdown support
|
||||
* DONE Post ordering as expected
|
||||
* DONE Stickies!
|
||||
* DONE Search
|
||||
* DONE Post editing
|
||||
* TODO Configurable number of DB workers
|
||||
* TODO Match certain types of Diesel errors (esp. for "not found")
|
||||
* TODO Sketch out categories vs. tags system
|
||||
* TODO Quote button
|
||||
* TODO Multiquote buttons
|
||||
* TODO Pagination
|
||||
* TODO Multi-thread guest accounts
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
# landing page for inbox.tvl.su
|
||||
|
||||
{ depot, ... }:
|
||||
|
||||
depot.web.tvl.template {
|
||||
title = "TVL's public inbox";
|
||||
|
||||
# not hosted on whitby, so we need /latest
|
||||
staticUrl = "https://static.tvl.su/latest";
|
||||
|
||||
extraHead = ''
|
||||
<link rel="alternate" type="application/atom+xml" href="https://inbox.tvl.su/depot/new.atom" />
|
||||
'';
|
||||
|
||||
content = ''
|
||||
TVL's public inbox
|
||||
==================
|
||||
|
||||
This is the [public-inbox][] for [The Virus Lounge][TVL]. It is
|
||||
essentially like a pull-based mailing list, where we discuss
|
||||
anything related to our software or organisation, as well as
|
||||
receive patches from external users.
|
||||
|
||||
## Posting to the inbox
|
||||
|
||||
Anyone can send messages to the inbox by emailing
|
||||
**depot@tvl.su**.
|
||||
|
||||
## Accessing the inbox
|
||||
|
||||
There are several ways to access the inbox, depending on what is
|
||||
most convenient for your personal email workflow.
|
||||
|
||||
### Web browser
|
||||
|
||||
Go to [`/depot/`][inbox-html] to read the inbox in your web
|
||||
browser. This is the easiest way to access messages, and with an
|
||||
email client supporting `mailto:` links you can respond to
|
||||
messages from there, too.
|
||||
|
||||
### IMAP
|
||||
|
||||
The inbox is available via IMAP:
|
||||
|
||||
**Server:** `inbox.tvl.su`
|
||||
|
||||
**Port:** `993` (TLS enabled)
|
||||
|
||||
**Inbox:** `su.tvl.depot.0` (auto-discoverable)
|
||||
|
||||
You can use *any* credentials to log in, for example the username
|
||||
`anonymous` with the password `kittens`. The server will just
|
||||
ignore it.
|
||||
|
||||
TIP: There is a wrapper script in `//tools/fetch-depot-inbox` in
|
||||
the TVL depot which you can use to synchronise the maildir to your
|
||||
computer, which works for email clients like `notmuch`.
|
||||
|
||||
### Atom feed
|
||||
|
||||
An Atom feed [is available][feed] and should work with your
|
||||
favourite feed reader.
|
||||
|
||||
### NNTP
|
||||
|
||||
News readers can access the inbox via NNTP:
|
||||
|
||||
**Server:** `inbox.tvl.su`
|
||||
|
||||
**Port:** `563` (TLS enabled)
|
||||
|
||||
**Group:** `su.tvl.depot.0` (auto-discoverable)
|
||||
|
||||
No credentials are required to access the server.
|
||||
|
||||
[public-inbox]: https://public-inbox.org/README.html
|
||||
[TVL]: https://tvl.fyi
|
||||
[inbox-html]: https://inbox.tvl.su/depot/
|
||||
[feed]: https://inbox.tvl.su/depot/new.atom
|
||||
'';
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
eval "$(lorri direnv)"
|
||||
1
web/panettone/.gitignore
vendored
1
web/panettone/.gitignore
vendored
|
|
@ -1 +0,0 @@
|
|||
*.fasl
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
aspen
|
||||
tazjin
|
||||
sterni
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
{ depot, pkgs, ... }:
|
||||
|
||||
depot.nix.buildLisp.program {
|
||||
name = "panettone";
|
||||
|
||||
deps = with depot.third_party.lisp; [
|
||||
bordeaux-threads
|
||||
cl-json
|
||||
cl-ppcre
|
||||
cl-smtp
|
||||
cl-who
|
||||
str
|
||||
defclass-std
|
||||
drakma
|
||||
easy-routes
|
||||
hunchentoot
|
||||
lass
|
||||
local-time
|
||||
postmodern
|
||||
|
||||
depot.lisp.klatre
|
||||
];
|
||||
|
||||
srcs = [
|
||||
./panettone.asd
|
||||
./src/packages.lisp
|
||||
(pkgs.writeText "build.lisp" ''
|
||||
(defpackage build
|
||||
(:use :cl :alexandria)
|
||||
(:export :*migrations-dir* :*static-dir*))
|
||||
(in-package :build)
|
||||
(declaim (optimize (safety 3)))
|
||||
(defvar *migrations-dir* "${./src/migrations}")
|
||||
(defvar *static-dir* "${./src/static}")
|
||||
'')
|
||||
./src/util.lisp
|
||||
./src/css.lisp
|
||||
./src/email.lisp
|
||||
./src/inline-markdown.lisp
|
||||
./src/authentication.lisp
|
||||
./src/model.lisp
|
||||
./src/irc.lisp
|
||||
./src/panettone.lisp
|
||||
];
|
||||
|
||||
tests = {
|
||||
deps = with depot.third_party.lisp; [
|
||||
fiveam
|
||||
];
|
||||
|
||||
srcs = [
|
||||
./test/package.lisp
|
||||
./test/model_test.lisp
|
||||
./test/inline-markdown_test.lisp
|
||||
./test/util_test.lisp
|
||||
];
|
||||
|
||||
expression = "(fiveam:run!)";
|
||||
};
|
||||
|
||||
brokenOn = [
|
||||
"ecl" # dependencies use dynamic cffi
|
||||
"ccl" # The value NIL is not of the expected type STRING. when loading model.lisp
|
||||
];
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
version: "3.4"
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_USER: panettone
|
||||
POSTGRES_PASSWORD: password
|
||||
POSTGRES_DB: panettone
|
||||
ports:
|
||||
- 127.0.0.1:5432:5432
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
(asdf:defsystem "panettone"
|
||||
:description "A simple issue tracker"
|
||||
:serial t
|
||||
:components ((:file "packages")
|
||||
(:file "css")
|
||||
(:file "pannetone")))
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
{ depot ? import ../.. { } }:
|
||||
|
||||
with depot.third_party.nixpkgs;
|
||||
|
||||
mkShell {
|
||||
buildInputs = [
|
||||
docker-compose
|
||||
postgresql
|
||||
];
|
||||
|
||||
PGPASSWORD = "password";
|
||||
PGHOST = "localhost";
|
||||
PGUSER = "panettone";
|
||||
PGDATABASE = "panettone";
|
||||
}
|
||||
2
web/panettone/src/.gitignore
vendored
2
web/panettone/src/.gitignore
vendored
|
|
@ -1,2 +0,0 @@
|
|||
# I use this as the out-link for my local lisp dev env
|
||||
sbcl
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
(in-package :panettone.authentication)
|
||||
|
||||
(defvar *user* nil
|
||||
"The currently logged-in user")
|
||||
|
||||
(defclass/std user ()
|
||||
((cn dn mail displayname :type string)))
|
||||
|
||||
;; Migrating user authentication to OAuth2 necessitates some temporary
|
||||
;; workarounds while other parts of the panettone code are being
|
||||
;; amended appropriately.
|
||||
|
||||
(defun fake-dn (username)
|
||||
"Users are no longer read directly from LDAP, but everything in
|
||||
panettone is keyed on the DNs. This function constructs matching
|
||||
'fake' DNs."
|
||||
(format nil "cn=~A,ou=users,dc=tvl,dc=fyi" username))
|
||||
|
||||
(defun find-user-by-dn (dn)
|
||||
"Previously this function looked up users in LDAP based on their DN,
|
||||
however panettone now does not have direct access to a user database.
|
||||
|
||||
For most cases only the username is needed, which can be parsed out of
|
||||
the user, however email addresses are temporarily not available."
|
||||
(let ((username
|
||||
(car (uiop:split-string (subseq dn 3) :separator '(#\,)))))
|
||||
(make-instance
|
||||
'user
|
||||
:dn dn
|
||||
:cn username
|
||||
:displayname username
|
||||
:mail nil)))
|
||||
|
||||
;; Implementation of standard OAuth2 authorisation flow.
|
||||
|
||||
(defvar *oauth2-auth-endpoint* nil)
|
||||
(defvar *oauth2-token-endpoint* nil)
|
||||
(defvar *oauth2-client-id* nil)
|
||||
(defvar *oauth2-client-secret* nil)
|
||||
(defvar *oauth2-redirect-uri* nil)
|
||||
|
||||
(comment
|
||||
(setq *oauth2-redirect-uri* "http://localhost:6161/auth")
|
||||
)
|
||||
|
||||
(defun initialise-oauth2 ()
|
||||
"Initialise all settings needed for OAuth2"
|
||||
|
||||
(setq *oauth2-auth-endpoint*
|
||||
(or *oauth2-auth-endpoint*
|
||||
(uiop:getenv "OAUTH2_AUTH_ENDPOINT")
|
||||
"https://auth.tvl.fyi/auth/realms/TVL/protocol/openid-connect/auth"))
|
||||
|
||||
(setq *oauth2-token-endpoint*
|
||||
(or *oauth2-token-endpoint*
|
||||
(uiop:getenv "OAUTH2_TOKEN_ENDPOINT")
|
||||
"https://auth.tvl.fyi/auth/realms/TVL/protocol/openid-connect/token"))
|
||||
|
||||
(setq *oauth2-client-id*
|
||||
(or *oauth2-client-id*
|
||||
(uiop:getenv "OAUTH2_CLIENT_ID")
|
||||
"panettone"))
|
||||
|
||||
(setq *oauth2-redirect-uri*
|
||||
(or (uiop:getenv "OAUTH2_REDIRECT_URI")
|
||||
"https://b.tvl.fyi/auth"))
|
||||
|
||||
(setq *oauth2-client-secret*
|
||||
(or *oauth2-client-secret*
|
||||
(uiop:getenv "OAUTH2_CLIENT_SECRET")
|
||||
(error "OAUTH2_CLIENT_SECRET must be set!"))))
|
||||
|
||||
(defun auth-url ()
|
||||
(format nil "~A?response_type=code&client_id=~A&redirect_uri=~A"
|
||||
*oauth2-auth-endpoint*
|
||||
(drakma:url-encode *oauth2-client-id* :utf-8)
|
||||
(drakma:url-encode *oauth2-redirect-uri* :utf-8)))
|
||||
|
||||
(defun claims-to-user (claims)
|
||||
(let ((username (cdr (assoc :preferred--username claims)))
|
||||
(email (cdr (assoc :email claims))))
|
||||
(make-instance
|
||||
'user
|
||||
:dn (fake-dn username)
|
||||
:cn username
|
||||
:mail email
|
||||
;; TODO(tazjin): Figure out actual displayName mapping in tokens.
|
||||
:displayname username)))
|
||||
|
||||
(defun fetch-token (code)
|
||||
"Fetches the access token on completion of user authentication through
|
||||
the OAuth2 endpoint and returns the resulting user object."
|
||||
|
||||
(multiple-value-bind (body status)
|
||||
(drakma:http-request *oauth2-token-endpoint*
|
||||
:method :post
|
||||
:parameters `(("grant_type" . "authorization_code")
|
||||
("client_id" . ,*oauth2-client-id*)
|
||||
("client_secret" . ,*oauth2-client-secret*)
|
||||
("redirect_uri" . ,*oauth2-redirect-uri*)
|
||||
("code" . ,code))
|
||||
:external-format-out :utf-8
|
||||
:want-stream t)
|
||||
(if (/= status 200)
|
||||
(error "Authentication failed: ~A (~A)~%"
|
||||
(alexandria:read-stream-content-into-string body)
|
||||
status)
|
||||
|
||||
;; Returned JWT contains username and email, we can populate
|
||||
;; all fields from that.
|
||||
(progn
|
||||
(setf (flexi-streams:flexi-stream-external-format body) :utf-8)
|
||||
(let* ((response (cl-json:decode-json body))
|
||||
(access-token (cdr (assoc :access--token response)))
|
||||
(payload (cadr (uiop:split-string access-token :separator '(#\.))))
|
||||
(claims (cl-json:decode-json-from-string
|
||||
(base64:base64-string-to-string
|
||||
;; The JWT spec specifies that base64 strings
|
||||
;; embedded in jwts are *not* padded, but the common
|
||||
;; lisp base64 library doesn't know how to deal with
|
||||
;; that - we need to add those extra padding
|
||||
;; characters here.
|
||||
(panettone.util:add-missing-base64-padding payload)))))
|
||||
(claims-to-user claims))))))
|
||||
|
|
@ -1,251 +0,0 @@
|
|||
(in-package :panettone.css)
|
||||
(declaim (optimize (safety 3)))
|
||||
|
||||
(defun button (selector)
|
||||
`((,selector
|
||||
:background-color "var(--success)"
|
||||
:padding "0.5rem"
|
||||
:text-decoration "none"
|
||||
:transition "box-shadow" "0.15s" "ease-in-out")
|
||||
|
||||
((:and ,selector :hover)
|
||||
:box-shadow "0.25rem" "0.25rem" "0" "0" "rgba(0,0,0,0.08)")
|
||||
|
||||
((:and ,selector (:or :active :focus))
|
||||
:box-shadow "0.1rem" "0.1rem" "0" "0" "rgba(0,0,0,0.05)"
|
||||
:outline "none"
|
||||
:border "none")))
|
||||
|
||||
(defparameter markdown-styles
|
||||
`((blockquote
|
||||
:border-left "5px" "solid" "var(--light)"-gray
|
||||
:padding-left "1rem"
|
||||
:margin-left "0rem")
|
||||
(pre
|
||||
:overflow-x "auto")))
|
||||
|
||||
(defparameter issue-list-styles
|
||||
`((.issue-list
|
||||
:list-style-type "none"
|
||||
:padding-left 0
|
||||
|
||||
(.issue-subject
|
||||
:font-weight "bold")
|
||||
|
||||
(li
|
||||
:padding-bottom "1rem")
|
||||
|
||||
((li + li)
|
||||
:border-top "1px" "solid" "var(--gray)")
|
||||
|
||||
(a
|
||||
:text-decoration "none"
|
||||
:display "block")
|
||||
|
||||
((:and a :hover)
|
||||
:outline "none"
|
||||
|
||||
(.issue-subject
|
||||
:color "var(--primary)")))
|
||||
|
||||
(.comment-count
|
||||
:color "var(--gray)")
|
||||
|
||||
(.issue-links
|
||||
:display "flex"
|
||||
:flex-direction "row"
|
||||
:align-items "center"
|
||||
:justify-content "space-between"
|
||||
:flex-wrap "wrap")
|
||||
|
||||
(.issue-search
|
||||
((:and input (:= type "search"))
|
||||
:padding "0.5rem"
|
||||
:background-image "url('static/search.png')"
|
||||
:background-position "10px 10px"
|
||||
:background-repeat "no-repeat"
|
||||
:background-size "1rem"
|
||||
:padding-left "2rem"
|
||||
:border "1px" "solid" "var(--gray)"))))
|
||||
|
||||
(defparameter issue-history-styles
|
||||
`((.issue-history
|
||||
:list-style "none"
|
||||
:border-top "1px" "solid" "var(--gray)"
|
||||
:padding-top "1rem"
|
||||
:padding-left "2rem"
|
||||
|
||||
(.comment-info
|
||||
:color "var(--gray)"
|
||||
:margin 0
|
||||
:padding-top "1rem"
|
||||
|
||||
(a :text-decoration "none")
|
||||
((:and a :hover)
|
||||
:text-decoration "underline"))
|
||||
|
||||
((:or .comment .event)
|
||||
:padding-top "1rem"
|
||||
:padding-bottom "1rem"
|
||||
:border-bottom "1px" "solid" "var(--gray)"
|
||||
|
||||
(p :margin 0))
|
||||
|
||||
((:and (:or .comment .event) :target)
|
||||
:border-color "var(--primary)"
|
||||
:border-bottom-width "3px")
|
||||
|
||||
(.event
|
||||
:color "var(--gray)"))))
|
||||
|
||||
(defparameter form-styles
|
||||
`(((:or (:and input (:or (:= type "text")
|
||||
(:= type "password")))
|
||||
textarea)
|
||||
:width "100%"
|
||||
:padding "0.5rem"
|
||||
:outline "none"
|
||||
:border-top "none"
|
||||
:border-left "none"
|
||||
:border-right "none"
|
||||
:border-bottom "1px" "solid" "var(--gray)"
|
||||
:margin-bottom "1rem")
|
||||
|
||||
(textarea
|
||||
:resize "vertical")
|
||||
|
||||
((:and input (:= type "submit"))
|
||||
:-webkit-appearance "none"
|
||||
:border "none"
|
||||
:cursor "pointer"
|
||||
:font-size "1rem")
|
||||
|
||||
,@(button '(:and input (:= type "submit")))
|
||||
|
||||
(.form-link
|
||||
((:and input (:= type "submit"))
|
||||
:background-color "initial"
|
||||
:color "inherit"
|
||||
:padding 0
|
||||
:text-decoration "underline")
|
||||
|
||||
((:and input (:= type "submit")
|
||||
(:or :hover :active :focus))
|
||||
:box-shadow 0 0 0 0))
|
||||
|
||||
(.form-group
|
||||
:margin-top "1rem")
|
||||
|
||||
(label.checkbox
|
||||
:cursor "pointer")))
|
||||
|
||||
(defparameter issue-styles
|
||||
`((.issue-info
|
||||
:display "flex"
|
||||
:justify-content "space-between"
|
||||
:align-items "center"
|
||||
|
||||
,@(button '.edit-issue)
|
||||
|
||||
(.created-by-at
|
||||
:flex 1)
|
||||
|
||||
(.edit-issue
|
||||
:background-color "var(--light)"-gray
|
||||
:flex 0
|
||||
:margin-right "0.5rem")
|
||||
|
||||
(.close-issue
|
||||
:background-color "var(--failure)"))))
|
||||
|
||||
(defparameter styles
|
||||
`(,@form-styles
|
||||
,@issue-list-styles
|
||||
,@issue-styles
|
||||
,@issue-history-styles
|
||||
,@markdown-styles
|
||||
|
||||
(body
|
||||
:font-family "sans-serif"
|
||||
:color "var(--text)"
|
||||
:background "var(--bg)"
|
||||
:--text "rgb(24, 24, 24)"
|
||||
:--bg "white"
|
||||
:--gray "#8D8D8D"
|
||||
:--primary "rgb(106, 154, 255)"
|
||||
:--primary-light "rgb(150, 166, 200)"
|
||||
:--success "rgb(168, 249, 166)"
|
||||
:--failure "rgb(247, 167, 167)"
|
||||
:--light-gray "#EEE")
|
||||
|
||||
(:media "(prefers-color-scheme: dark)"
|
||||
(body
|
||||
:--text "rgb(240, 240, 240)"
|
||||
:--bg "black"
|
||||
:--gray "#8D8D8D"
|
||||
:--primary "rgb(106, 154, 255)"
|
||||
:--primary-light "rgb(150, 166, 200)"
|
||||
:--success "rgb(14, 130, 11)"
|
||||
:--failure "rgb(124, 14, 14)"
|
||||
:--light-gray "#222"))
|
||||
|
||||
(a :color "inherit")
|
||||
|
||||
(.content
|
||||
:max-width "800px"
|
||||
:margin "0 auto")
|
||||
|
||||
(header
|
||||
:display "flex"
|
||||
:align-items "center"
|
||||
:border-bottom "1px" "solid" "var(--text)"
|
||||
:margin-bottom "1rem"
|
||||
|
||||
(h1
|
||||
:padding 0
|
||||
:flex 1)
|
||||
|
||||
(.issue-number
|
||||
:color "var(--gray)"
|
||||
:font-size "1.5rem"))
|
||||
|
||||
(nav
|
||||
:display "flex"
|
||||
:color "var(--gray)"
|
||||
:justify-content "space-between"
|
||||
|
||||
(.nav-group
|
||||
:display "flex"
|
||||
(>*
|
||||
:margin-left "0.5rem")))
|
||||
|
||||
(footer
|
||||
:border-top "1px" "solid" "var(--gray)"
|
||||
:padding-top "1rem"
|
||||
:margin-top "1rem"
|
||||
:color "var(--gray)")
|
||||
|
||||
,@(button '.new-issue)
|
||||
|
||||
(.alert
|
||||
:padding "0.5rem"
|
||||
:margin-bottom "1rem"
|
||||
:background-color "var(--failure)")
|
||||
|
||||
(.login-form
|
||||
:max-width "300px"
|
||||
:margin "0 auto")
|
||||
|
||||
(.created-by-at
|
||||
:color "var(--gray)")
|
||||
|
||||
;; screen-reader-only content
|
||||
(.sr-only
|
||||
:border 0
|
||||
:clip "rect(0 0 0 0)"
|
||||
:height "1px"
|
||||
:margin "-1px"
|
||||
:overflow "hidden"
|
||||
:padding 0
|
||||
:position "absolute"
|
||||
:width "1px")))
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
(in-package :panettone.email)
|
||||
(declaim (optimize (safety 3)))
|
||||
|
||||
(defvar *smtp-server* "localhost"
|
||||
"The host for SMTP connections")
|
||||
|
||||
(defvar *smtp-server-port* 2525
|
||||
"The port for SMTP connections")
|
||||
|
||||
(defvar *notification-from* "tvlbot@tazj.in"
|
||||
"The email address to send email notifications from")
|
||||
|
||||
(defvar *notification-from-display-name* "Panettone"
|
||||
"The Display Name to use when sending email notifications")
|
||||
|
||||
(defvar *notification-subject-prefix* "[panettone]"
|
||||
"String to prefix all email subjects with")
|
||||
|
||||
(defun send-email-notification (&key to subject message)
|
||||
"Sends an email to TO with the given SUBJECT and MESSAGE, using the current
|
||||
values of `*smtp-server*', `*smtp-server-port*' and `*email-notification-from*'"
|
||||
(let ((subject (if *notification-subject-prefix*
|
||||
(format nil "~A ~A"
|
||||
*notification-subject-prefix*
|
||||
subject)
|
||||
subject)))
|
||||
(cl-smtp:send-email
|
||||
*smtp-server*
|
||||
*notification-from*
|
||||
to
|
||||
subject
|
||||
message
|
||||
:port *smtp-server-port*
|
||||
:display-name *notification-from-display-name*)))
|
||||
|
||||
(defun user-has-email-notifications-enabled-p (dn)
|
||||
"Returns T if the user with the given DN has enabled email notifications"
|
||||
(enable-email-notifications-p (settings-for-user dn)))
|
||||
|
||||
(defun notify-user (dn &key subject message)
|
||||
"Sends an email notification to the user with DN with the given SUBJECT and
|
||||
MESSAGE, iff that user has not disabled email notifications"
|
||||
(when (user-has-email-notifications-enabled-p dn)
|
||||
(when-let* ((user (find-user-by-dn dn))
|
||||
(user-mail (mail user)))
|
||||
(send-email-notification
|
||||
:to user-mail
|
||||
:subject subject
|
||||
:message message))))
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
(in-package :panettone.inline-markdown)
|
||||
(declaim (optimize (safety 3)))
|
||||
|
||||
(define-constant +inline-markup-types+
|
||||
'(("~~" :del)
|
||||
("*" :em)
|
||||
("`" :code))
|
||||
:test #'equal)
|
||||
|
||||
(defun next-token (mkdn &optional (escaped nil))
|
||||
"Parses and returns the next token from the beginning of
|
||||
an inline markdown string which is not altered. The resulting
|
||||
tokens are either :normal (normal text), :special (syntactically
|
||||
significant) or :escaped (escaped using \\). If the string is
|
||||
empty, a pseudo-token named :endofinput is returned. Return value
|
||||
is a list where the first element is the token type, the second
|
||||
the token content and optionally the third the markup type."
|
||||
; special tokens are syntactically significant characters
|
||||
; or strings for our inline markdown subset. “normal” tokens
|
||||
; the strings in between
|
||||
(let* ((special-toks #.'(cons (list "\\" :escape) +inline-markup-types+))
|
||||
(toks (loop
|
||||
for tok in special-toks
|
||||
for pos = (search (car tok) mkdn)
|
||||
when pos collect (cons tok pos)))
|
||||
(next-tok
|
||||
(unless (null toks)
|
||||
(reduce (lambda (a b) (if (< (cdr a) (cdr b)) a b)) toks))))
|
||||
(cond
|
||||
; end of input
|
||||
((= (length mkdn) 0) (list :endofinput ""))
|
||||
; no special tokens, just return entire string
|
||||
((null next-tok) (list :normal mkdn))
|
||||
; special token, but not at the beginning of the string
|
||||
; so we return everything until the special token as
|
||||
; a string
|
||||
((> (cdr next-tok) 0) (list :normal (subseq mkdn 0 (cdr next-tok))))
|
||||
; \ at the beginning of the string: we get the next
|
||||
; token and mark it as escaped unless we are already
|
||||
; escaping in which case we just return the backslash
|
||||
; as a special token
|
||||
((eq (cadr (car next-tok)) :escape)
|
||||
(if escaped
|
||||
(list :special "\\")
|
||||
(list :escaped
|
||||
(next-token (subseq mkdn 1) t))))
|
||||
; any other special token at the beginning of the string
|
||||
; here we also pass the markup type as a third list element
|
||||
; to prevent unnecessesary lookups
|
||||
(t (list :special
|
||||
(subseq mkdn 0 (length (car (car next-tok))))
|
||||
(cadr (car next-tok)))))))
|
||||
|
||||
(defun token-length (tok-type tok-str)
|
||||
"Returns the string length consumed by a call
|
||||
to next-token returning the given token type and string."
|
||||
(check-type tok-type symbol)
|
||||
(if (eq tok-type :escaped)
|
||||
; backslash + length of escaped token
|
||||
(progn
|
||||
(check-type tok-str list)
|
||||
(1+ (token-length (car tok-str) (cadr tok-str))))
|
||||
(progn
|
||||
(check-type tok-str string)
|
||||
(length tok-str))))
|
||||
|
||||
(defun write-tag (tag pos &optional (target *standard-output*))
|
||||
"Wrapper around who:convert-tag-to-string-list to
|
||||
only output a single :opening or :closing tag."
|
||||
(check-type tag symbol)
|
||||
(check-type pos symbol)
|
||||
(let
|
||||
((index
|
||||
(cond
|
||||
((eq pos :opening) 0)
|
||||
((eq pos :closing) 3)
|
||||
(t (error 'simple-type-error)))))
|
||||
(dolist
|
||||
(tag-part (subseq
|
||||
(who:convert-tag-to-string-list tag nil nil nil)
|
||||
index (+ index 3)))
|
||||
(write-string tag-part target))))
|
||||
|
||||
(defun render-inline-markdown (s &optional (target *standard-output*) (in :normal))
|
||||
"Render inline markdown, a subset of markdown safe to render
|
||||
inside inline elements. The resulting html is directly written
|
||||
to a specified stream or *standard-output* to integrate well
|
||||
with cl-who."
|
||||
(check-type s string)
|
||||
(check-type target stream)
|
||||
(loop
|
||||
for (tok-type tok-str tok-markup) = (next-token s)
|
||||
do (setq s (subseq s (token-length tok-type tok-str)))
|
||||
when (eq tok-type :endofinput)
|
||||
return ""
|
||||
when (eq tok-type :normal)
|
||||
do (write-string (who:escape-string tok-str) target)
|
||||
when (eq tok-type :escaped)
|
||||
do (progn
|
||||
; if normal tokens are escaped we treat the \ as if it were \\
|
||||
;
|
||||
; TODO(sterni): maybe also use the :normal behavior in :code except for #\`.
|
||||
(when (eq (car tok-str) :normal)
|
||||
(write-char #\\ target))
|
||||
(write-string (who:escape-string (cadr tok-str)) target))
|
||||
when (eq tok-type :special)
|
||||
do (cond
|
||||
; we are on the outer level and encounter a special token:
|
||||
; render surrounding tags and call ourselves to render
|
||||
; inner content.
|
||||
((eq in :normal)
|
||||
(progn
|
||||
(write-tag tok-markup :opening target)
|
||||
(setq s (render-inline-markdown s target tok-markup))
|
||||
(write-tag tok-markup :closing target)))
|
||||
; we are on the inner level and encounter the token that initiated
|
||||
; our markup again, meaning we need to return to the outer level.
|
||||
; we return the remaining string to be consumed.
|
||||
((eq in tok-markup) (return s))
|
||||
; remaining case: we are on the inner level and encounter different markup.
|
||||
|
||||
; we don't support nested markup for simplicity reasons, so instead we
|
||||
; just render any nested markdown tokens as if they were escaped. This
|
||||
; only eliminates the slight use case for nesting :em inside :del, but
|
||||
; shouldn't be too bad. As a side effect this is the precise behavior
|
||||
; we want for :code.
|
||||
(t (write-string (who:escape-string tok-str) target)))))
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
;;;; Using irccat to send IRC notifications
|
||||
|
||||
(in-package :panettone.irc)
|
||||
|
||||
(defun noping (s)
|
||||
(format nil "~A~A~A"
|
||||
(char s 0)
|
||||
#\ZERO_WIDTH_SPACE
|
||||
(subseq s 1)))
|
||||
|
||||
(defun get-irccat-config ()
|
||||
"Reads the IRCCATHOST and IRCCATPORT environment variables, and returns them
|
||||
as two values"
|
||||
(destructuring-bind (host port)
|
||||
(mapcar #'uiop:getenvp '("IRCCATHOST" "IRCCATPORT"))
|
||||
(if (and host port)
|
||||
(values host (parse-integer port))
|
||||
(values "localhost" 4722))))
|
||||
|
||||
(defun send-irc-notification (body &key channel)
|
||||
"Sends BODY to the IRC channel CHANNEL (starting with #),
|
||||
if an IRCCat server is configured (using the IRCCATHOST and IRCCATPORT
|
||||
environment variables).
|
||||
May signal a condition if sending fails."
|
||||
(multiple-value-bind (irchost ircport) (get-irccat-config)
|
||||
(when irchost
|
||||
(let ((socket (socket-connect irchost ircport)))
|
||||
(unwind-protect
|
||||
(progn
|
||||
(format (socket-stream socket) "~@[~A ~]~A~A~%"
|
||||
channel
|
||||
#\ZERO_WIDTH_SPACE
|
||||
body)
|
||||
(finish-output (socket-stream socket)))
|
||||
(ignore-errors (socket-close socket)))))))
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
"Initialize the database schema from before migrations were added"
|
||||
|
||||
(defun ddl/create-issue-status ()
|
||||
"Issue DDL to create the `issue-status' type, if it doesn't exist"
|
||||
(unless (query (:select (:exists (:select 1
|
||||
:from 'pg_type
|
||||
:where (:= 'typname "issue_status"))))
|
||||
:single)
|
||||
(query (sql-compile
|
||||
`(:create-enum issue-status ,panettone.model:+issue-statuses+)))))
|
||||
|
||||
(defun ddl/create-tables ()
|
||||
"Issue DDL to create all tables, if they don't already exist."
|
||||
(dolist (table '(panettone.model:issue
|
||||
panettone.model:issue-comment
|
||||
panettone.model:issue-event
|
||||
panettone.model:user-settings))
|
||||
(unless (table-exists-p (dao-table-name table))
|
||||
(create-table table))))
|
||||
|
||||
(defun up ()
|
||||
(ddl/create-issue-status)
|
||||
(ddl/create-tables))
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
"Add tsvector for full-text search of issues"
|
||||
|
||||
(defun up ()
|
||||
(query "ALTER TABLE issues ADD COLUMN tsv tsvector GENERATED ALWAYS AS (to_tsvector('english', subject || ' ' || body)) STORED")
|
||||
(query "CREATE INDEX issues_tsv_index ON issues USING GIN (tsv);"))
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
"Add a table to store information about users, load the initial set of users
|
||||
from the authentication provider, and change fks for other tables"
|
||||
|
||||
(defun up ()
|
||||
(panettone.model:create-table-if-not-exists
|
||||
'panettone.model:user))
|
||||
|
|
@ -1,608 +0,0 @@
|
|||
(in-package :panettone.model)
|
||||
(declaim (optimize (safety 3)))
|
||||
|
||||
(setq pomo:*ignore-unknown-columns* t)
|
||||
|
||||
(defvar *pg-spec* nil
|
||||
"Connection spec for use with the with-connection macro. Needs to be
|
||||
initialised at launch time.")
|
||||
|
||||
(defun make-pg-spec ()
|
||||
"Construct the Postgres connection spec from the environment."
|
||||
(list (or (uiop:getenvp "PGDATABASE") "panettone")
|
||||
(or (uiop:getenvp "PGUSER") "panettone")
|
||||
(or (uiop:getenvp "PGPASSWORD") "password")
|
||||
(or (uiop:getenvp "PGHOST") "localhost")
|
||||
|
||||
:port (or (integer-env "PGPORT") 5432)
|
||||
:application-name "panettone"
|
||||
:pooled-p t))
|
||||
|
||||
(defun prepare-db-connections ()
|
||||
"Initialises the connection spec used for all Postgres connections."
|
||||
(setq *pg-spec* (make-pg-spec)))
|
||||
|
||||
(defun connect-to-db ()
|
||||
"Connect using *PG-SPEC* at the top-level, for use during development"
|
||||
(apply #'connect-toplevel
|
||||
(loop for v in *pg-spec*
|
||||
until (eq v :pooled-p)
|
||||
collect v)))
|
||||
|
||||
(defun pg-spec->url (&optional (spec *pg-spec*))
|
||||
(destructuring-bind (db user password host &key port &allow-other-keys) spec
|
||||
(format nil
|
||||
"postgres://~A:~A@~A:~A/~A"
|
||||
user password host port db)))
|
||||
|
||||
;;;
|
||||
;;; Schema
|
||||
;;;
|
||||
|
||||
(defclass user ()
|
||||
((sub :col-type uuid :initarg :sub :accessor sub
|
||||
:documentation
|
||||
"ID for the user in the authentication provider. Taken from the `:SUB'
|
||||
field in the JWT when the user first logged in")
|
||||
(username :col-type string :initarg :username :accessor username)
|
||||
(email :col-type string :initarg :email :accessor email))
|
||||
(:metaclass dao-class)
|
||||
(:keys sub)
|
||||
(:table-name users)
|
||||
(:documentation
|
||||
"Panettone users. Uses an external authentication provider."))
|
||||
|
||||
(deftable (user "users")
|
||||
(!dao-def))
|
||||
|
||||
(defclass user-settings ()
|
||||
((user-dn :col-type string :initarg :user-dn :accessor user-dn)
|
||||
(enable-email-notifications
|
||||
:col-type boolean
|
||||
:initarg :enable-email-notifications
|
||||
:accessor enable-email-notifications-p
|
||||
:initform t
|
||||
:col-default t))
|
||||
(:metaclass dao-class)
|
||||
(:keys user-dn)
|
||||
(:table-name user_settings)
|
||||
(:documentation
|
||||
"Panettone settings for an individual user DN"))
|
||||
|
||||
(deftable (user-settings "user_settings")
|
||||
(!dao-def))
|
||||
|
||||
(defun settings-for-user (dn)
|
||||
"Retrieve the settings for the user with the given DN, creating a new row in
|
||||
the database if not yet present"
|
||||
(or
|
||||
(car
|
||||
(query-dao
|
||||
'user-settings
|
||||
(:select '* :from 'user-settings :where (:= 'user-dn dn))))
|
||||
(insert-dao (make-instance 'user-settings :user-dn dn))))
|
||||
|
||||
(defun update-user-settings (settings &rest attrs)
|
||||
"Update the fields of the settings for USER with the given ATTRS, which is a
|
||||
plist of slot and value"
|
||||
(check-type settings user-settings)
|
||||
(when-let ((set-fields
|
||||
(iter
|
||||
(for slot in '(enable-email-notifications))
|
||||
(for new-value = (getf attrs slot))
|
||||
(appending
|
||||
(progn
|
||||
(setf (slot-value settings slot) new-value)
|
||||
(list slot new-value))))))
|
||||
(execute
|
||||
(sql-compile
|
||||
`(:update user-settings
|
||||
:set ,@set-fields
|
||||
:where (:= user-dn ,(user-dn settings)))))))
|
||||
|
||||
|
||||
(define-constant +issue-statuses+ '(:open :closed)
|
||||
:test #'equal)
|
||||
|
||||
(deftype issue-status ()
|
||||
"Type specifier for the status of an `issue'"
|
||||
(cons 'member +issue-statuses+))
|
||||
|
||||
(defclass has-created-at ()
|
||||
((created-at :col-type timestamp
|
||||
:col-default (local-time:now)
|
||||
:initarg :created-at
|
||||
:accessor created-at))
|
||||
(:metaclass dao-class))
|
||||
|
||||
(defun created-at->timestamp (object)
|
||||
(assert (slot-exists-p object 'created-at))
|
||||
(unless (or (not (slot-boundp object 'created-at))
|
||||
(typep (slot-value object 'created-at) 'local-time:timestamp))
|
||||
(setf (slot-value object 'created-at)
|
||||
(local-time:universal-to-timestamp (created-at object)))))
|
||||
|
||||
(defmethod initialize-instance :after
|
||||
((obj has-created-at) &rest initargs &key &allow-other-keys)
|
||||
(declare (ignore initargs))
|
||||
(created-at->timestamp obj))
|
||||
|
||||
(defun keyword->str (kw) (string-downcase (symbol-name kw)))
|
||||
(defun str->keyword (st) (alexandria:make-keyword (string-upcase st)))
|
||||
|
||||
(defclass issue (has-created-at)
|
||||
((id :col-type serial :initarg :id :accessor id)
|
||||
(subject :col-type string :initarg :subject :accessor subject)
|
||||
(body :col-type string :initarg :body :accessor body :col-default "")
|
||||
(author-dn :col-type string :initarg :author-dn :accessor author-dn)
|
||||
(comments :type list :accessor issue-comments)
|
||||
(events :type list :accessor issue-events)
|
||||
(num-comments :type integer :accessor num-comments)
|
||||
(status :col-type issue_status
|
||||
:initarg :status
|
||||
:accessor status
|
||||
:initform :open
|
||||
:col-default "open"
|
||||
:col-export keyword->str
|
||||
:col-import str->keyword))
|
||||
(:metaclass dao-class)
|
||||
(:keys id)
|
||||
(:table-name issues)
|
||||
(:documentation
|
||||
"Issues are the primary entity in the Panettone database. An issue is
|
||||
reported by a user, has a subject and an optional body, and can be either
|
||||
open or closed"))
|
||||
|
||||
(defmethod cl-postgres:to-sql-string ((kw (eql :open)))
|
||||
(cl-postgres:to-sql-string "open"))
|
||||
(defmethod cl-postgres:to-sql-string ((kw (eql :closed)))
|
||||
(cl-postgres:to-sql-string "closed"))
|
||||
(defmethod cl-postgres:to-sql-string ((ts local-time:timestamp))
|
||||
(cl-postgres:to-sql-string
|
||||
(local-time:timestamp-to-unix ts)))
|
||||
|
||||
(defmethod initialize-instance :after
|
||||
((issue issue) &rest initargs &key &allow-other-keys)
|
||||
(declare (ignore initargs))
|
||||
(unless (symbolp (status issue))
|
||||
(setf (status issue)
|
||||
(intern (string-upcase (status issue))
|
||||
"KEYWORD"))))
|
||||
|
||||
(deftable issue (!dao-def))
|
||||
|
||||
(defclass issue-comment (has-created-at)
|
||||
((id :col-type integer :col-identity t :initarg :id :accessor id)
|
||||
(body :col-type string :initarg :body :accessor body)
|
||||
(author-dn :col-type string :initarg :author-dn :accessor author-dn)
|
||||
(issue-id :col-type integer :initarg :issue-id :accessor :user-id))
|
||||
(:metaclass dao-class)
|
||||
(:keys id)
|
||||
(:table-name issue_comments)
|
||||
(:documentation "Comments on an `issue'"))
|
||||
(deftable (issue-comment "issue_comments")
|
||||
(!dao-def)
|
||||
(!foreign 'issues 'issue-id 'id :on-delete :cascade :on-update :cascade))
|
||||
|
||||
(defclass issue-event (has-created-at)
|
||||
((id :col-type integer :col-identity t :initarg :id :accessor id)
|
||||
(issue-id :col-type integer
|
||||
:initarg :issue-id
|
||||
:accessor issue-id)
|
||||
(acting-user-dn :col-type string
|
||||
:initarg :acting-user-dn
|
||||
:accessor acting-user-dn)
|
||||
(field :col-type (or string db-null)
|
||||
:initarg :field
|
||||
:accessor field)
|
||||
(previous-value :col-type (or string db-null)
|
||||
:initarg :previous-value
|
||||
:accessor previous-value)
|
||||
(new-value :col-type (or string db-null)
|
||||
:initarg :new-value
|
||||
:accessor new-value))
|
||||
(:metaclass dao-class)
|
||||
(:keys id)
|
||||
(:table-name issue_events)
|
||||
(:documentation "Events that have occurred for an issue.
|
||||
|
||||
If a field has been changed on an issue, the SYMBOL-NAME of that slot will be in
|
||||
FIELD, its previous value will be formatted using ~A into PREVIOUS-VALUE, and
|
||||
its new value will be formatted using ~A into NEW-VALUE"))
|
||||
|
||||
(deftable (issue-event "issue_events")
|
||||
(!dao-def)
|
||||
(!foreign 'issues 'issue-id 'id :on-delete :cascade :on-update :cascade))
|
||||
|
||||
(defclass migration ()
|
||||
((version
|
||||
:col-type bigint
|
||||
:primary-key t
|
||||
:initarg :version
|
||||
:accessor version)
|
||||
(name :col-type string :initarg :name :accessor name)
|
||||
(docstring :col-type string :initarg :docstring :accessor docstring)
|
||||
(path :col-type string
|
||||
:type pathname
|
||||
:initarg :path
|
||||
:accessor path
|
||||
:col-export namestring
|
||||
:col-import parse-namestring)
|
||||
(package :type keyword :initarg :package :accessor migration-package))
|
||||
(:metaclass dao-class)
|
||||
(:keys version)
|
||||
(:table-name migrations)
|
||||
(:documentation "Migration scripts that have been run on the database"))
|
||||
(deftable migration (!dao-def))
|
||||
|
||||
;;;
|
||||
;;; Utils
|
||||
;;;
|
||||
|
||||
(defun create-table-if-not-exists (name)
|
||||
" Takes the name of a dao-class and creates the table identified by symbol by
|
||||
executing all forms in its definition as found in the *tables* list, if it does
|
||||
not already exist."
|
||||
(unless (table-exists-p (dao-table-name name))
|
||||
(create-table name)))
|
||||
|
||||
;;;
|
||||
;;; Migrations
|
||||
;;;
|
||||
|
||||
(defun ensure-migrations-table ()
|
||||
"Ensure the migrations table exists"
|
||||
(unless (table-exists-p (dao-table-name 'migration))
|
||||
(create-table 'migration)))
|
||||
|
||||
(define-build-time-var *migrations-dir* "migrations/"
|
||||
"The directory where migrations are stored")
|
||||
|
||||
(defun load-migration-docstring (migration-path)
|
||||
"If the first form in the file pointed to by `migration-pathname` is
|
||||
a string, return it, otherwise return NIL."
|
||||
|
||||
(handler-case
|
||||
(with-open-file (s migration-path)
|
||||
(when-let ((form (read s)))
|
||||
(when (stringp form) form)))
|
||||
(t () nil)))
|
||||
|
||||
(defun load-migration (path)
|
||||
(let* ((parts (str:split #\- (pathname-name path) :limit 2))
|
||||
(version (parse-integer (car parts)))
|
||||
(name (cadr parts))
|
||||
(docstring (load-migration-docstring path))
|
||||
(package (intern (format nil "MIGRATION-~A" version)
|
||||
:keyword))
|
||||
(migration (make-instance 'migration
|
||||
:version version
|
||||
:name name
|
||||
:docstring docstring
|
||||
:path path
|
||||
:package package)))
|
||||
(uiop/package:ensure-package package
|
||||
:use '(#:common-lisp
|
||||
#:postmodern
|
||||
#:panettone.model))
|
||||
(let ((*package* (find-package package)))
|
||||
(load path))
|
||||
|
||||
migration))
|
||||
|
||||
(defun run-migration (migration)
|
||||
(declare (type migration migration))
|
||||
(with-transaction ()
|
||||
(format t "Running migration ~A (version ~A)"
|
||||
(name migration)
|
||||
(version migration))
|
||||
(query
|
||||
(sql-compile
|
||||
`(:delete-from migrations
|
||||
:where (= version ,(version migration)))))
|
||||
(uiop:symbol-call (migration-package migration) :up)
|
||||
(insert-dao migration)))
|
||||
|
||||
(defun list-migration-files ()
|
||||
(remove-if-not
|
||||
(lambda (pn) (string= "lisp" (pathname-type pn)))
|
||||
(uiop:directory-files (util:->dir *migrations-dir*))))
|
||||
|
||||
(defun load-migrations ()
|
||||
(mapcar #'load-migration (list-migration-files)))
|
||||
|
||||
(defun generate-migration (name &key documentation)
|
||||
"Generate a new database migration with the given NAME, optionally
|
||||
prepopulated with the given DOCUMENTATION.
|
||||
|
||||
Returns the file that the migration is located at, as a `pathname'. Write Lisp
|
||||
code in this migration file to define a function called `up', which will be run
|
||||
in the context of a database transaction and should perform the migration."
|
||||
(let* ((version (get-universal-time))
|
||||
(filename (format nil "~A-~A.lisp"
|
||||
version
|
||||
name))
|
||||
(pathname
|
||||
(merge-pathnames filename *migrations-dir*)))
|
||||
(with-open-file (stream pathname
|
||||
:direction :output
|
||||
:if-does-not-exist :create)
|
||||
(when documentation
|
||||
(format stream "~S~%~%" documentation))
|
||||
|
||||
(format stream "(defun up ()~%)"))
|
||||
pathname))
|
||||
|
||||
(defun migrations-already-run ()
|
||||
"Query the database for a list of migrations that have already been run"
|
||||
(query-dao 'migration (sql-compile '(:select * :from migrations))))
|
||||
|
||||
(define-condition migration-name-mismatch ()
|
||||
((version :type integer :initarg :version)
|
||||
(name-in-database :type string :initarg :name-in-database)
|
||||
(name-in-code :type string :initarg :name-in-code))
|
||||
(:report
|
||||
(lambda (cond stream)
|
||||
(format stream "Migration mismatch: Migration version ~A has name ~S in the database, but we have name ~S"
|
||||
(slot-value cond 'version)
|
||||
(slot-value cond 'name-in-database)
|
||||
(slot-value cond 'name-in-code)))))
|
||||
|
||||
(defun migrate ()
|
||||
"Migrate the database, running all migrations that have not yet been run"
|
||||
(ensure-migrations-table)
|
||||
(format t "Running migrations from ~A...~%" *migrations-dir*)
|
||||
(let* ((all-migrations (load-migrations))
|
||||
(already-run (migrations-already-run))
|
||||
(num-migrations-run 0))
|
||||
(iter (for migration in all-migrations)
|
||||
(if-let ((existing (find-if (lambda (existing)
|
||||
(= (version existing)
|
||||
(version migration)))
|
||||
already-run)))
|
||||
(progn
|
||||
(unless (string= (name migration)
|
||||
(name existing))
|
||||
(restart-case
|
||||
(error 'migration-name-mismatch
|
||||
:version (version existing)
|
||||
:name-in-database (name existing)
|
||||
:name-in-code (name migration))
|
||||
(skip ()
|
||||
:report "Skip this migration"
|
||||
(next-iteration))
|
||||
(run-and-overwrite ()
|
||||
:report "Run this migration anyway, overwriting the previous migration"
|
||||
(run-migration migration))))
|
||||
(next-iteration))
|
||||
;; otherwise, run the migration
|
||||
(run-migration migration))
|
||||
(incf num-migrations-run))
|
||||
(format t "Ran ~A migration~:P~%" num-migrations-run)))
|
||||
|
||||
;;;
|
||||
;;; Querying
|
||||
;;;
|
||||
|
||||
(define-condition issue-not-found (error)
|
||||
((id :type integer
|
||||
:initarg :id
|
||||
:reader not-found-id
|
||||
:documentation "ID of the issue that was not found"))
|
||||
(:documentation
|
||||
"Error condition for when an issue requested by ID is not found"))
|
||||
|
||||
(defun get-issue (id)
|
||||
"Look up the 'issue with the given ID and return it, or signal a condition of
|
||||
type `ISSUE-NOT-FOUND'."
|
||||
(restart-case
|
||||
(or (get-dao 'issue id)
|
||||
(error 'issue-not-found :id id))
|
||||
(different-id (new-id)
|
||||
:report "Use a different issue ID"
|
||||
:interactive (lambda ()
|
||||
(format t "Enter a new ID: ")
|
||||
(multiple-value-list (eval (read))))
|
||||
(get-issue new-id))))
|
||||
|
||||
(defun issue-exists-p (id)
|
||||
"Returns `T' if an issue with the given ID exists"
|
||||
(query
|
||||
(:select (:exists (:select 1
|
||||
:from 'issues
|
||||
:where (:= 'id id))))
|
||||
:single))
|
||||
|
||||
(defun list-issues (&key status search (with '(:num-comments)))
|
||||
"Return a list of all issues with the given STATUS (or all if nil), ordered by
|
||||
ID descending. If WITH contains `:NUM-COMMENTS' (the default) each issue will
|
||||
have the `num-comments' slot filled with the number of comments on that issue
|
||||
(to avoid N+1 queries)."
|
||||
(let* ((conditions
|
||||
(and-where*
|
||||
(unless (null status)
|
||||
`(:= status $1))
|
||||
(when (str:non-blank-string-p search)
|
||||
`(:@@ tsv (:websearch-to-tsquery ,search)))))
|
||||
(select (if (find :num-comments with)
|
||||
`(:select issues.* (:as (:count issue-comments.id)
|
||||
num-comments)
|
||||
:from issues
|
||||
:left-join issue-comments
|
||||
:on (:= issues.id issue-comments.issue-id)
|
||||
:where ,conditions
|
||||
:group-by issues.id)
|
||||
`(:select * :from issues :where ,conditions)))
|
||||
(order (if (str:non-blank-string-p search)
|
||||
`(:desc (:ts-rank-cd tsv (:websearch-to-tsquery ,search)))
|
||||
`(:desc id)))
|
||||
(query (sql-compile
|
||||
`(:order-by ,select ,order))))
|
||||
(with-column-writers ('num_comments 'num-comments)
|
||||
(query-dao 'issue query status))))
|
||||
|
||||
(defmethod count-comments ((issue-id integer))
|
||||
"Return the number of comments for the given ISSUE-ID."
|
||||
(query
|
||||
(:select (:count '*)
|
||||
:from 'issue-comments
|
||||
:where (:= 'issue-id issue-id))
|
||||
:single))
|
||||
|
||||
(defmethod slot-unbound (cls (issue issue) (slot (eql 'comments)))
|
||||
(declare (ignore cls) (ignore slot))
|
||||
(setf (issue-comments issue) (issue-comments (id issue))))
|
||||
|
||||
(defmethod issue-comments ((issue-id integer))
|
||||
"Return a list of all comments with the given ISSUE-ID, sorted oldest first.
|
||||
NOTE: This makes a database query, so be wary of N+1 queries"
|
||||
(query-dao
|
||||
'issue-comment
|
||||
(:order-by
|
||||
(:select '*
|
||||
:from 'issue-comments
|
||||
:where (:= 'issue-id issue-id))
|
||||
(:asc 'created-at))))
|
||||
|
||||
(defmethod slot-unbound (cls (issue issue) (slot (eql 'events)))
|
||||
(declare (ignore cls) (ignore slot))
|
||||
(setf (issue-events issue) (issue-events (id issue))))
|
||||
|
||||
(defmethod issue-events ((issue-id integer))
|
||||
"Return a list of all events with the given ISSUE-ID, sorted oldest first.
|
||||
NOTE: This makes a database query, so be wary of N+1 queries"
|
||||
(query-dao
|
||||
'issue-event
|
||||
(:order-by
|
||||
(:select '*
|
||||
:from 'issue-events
|
||||
:where (:= 'issue-id issue-id))
|
||||
(:asc 'created-at))))
|
||||
|
||||
;;;
|
||||
;;; Writing
|
||||
;;;
|
||||
|
||||
(defun record-issue-event
|
||||
(issue-id &key
|
||||
field
|
||||
previous-value
|
||||
new-value)
|
||||
"Record in the database that the user identified by `AUTHN:*USER*' updated
|
||||
ISSUE-ID, and return the resulting `ISSUE-EVENT'. If no user is currently
|
||||
authenticated, warn and no-op"
|
||||
(check-type issue-id (integer))
|
||||
(check-type field (or null symbol))
|
||||
(if authn:*user*
|
||||
(insert-dao
|
||||
(make-instance 'issue-event
|
||||
:issue-id issue-id
|
||||
:acting-user-dn (authn:dn authn:*user*)
|
||||
:field (symbol-name field)
|
||||
:previous-value (when previous-value
|
||||
(format nil "~A" previous-value))
|
||||
:new-value (when new-value
|
||||
(format nil "~A" new-value))))
|
||||
(warn "Performing operation as unauthenticated user")))
|
||||
|
||||
(defun create-issue (&rest attrs)
|
||||
"Insert a new issue into the database with the given ATTRS, which should be
|
||||
a plist of initforms, and return an instance of `issue'"
|
||||
(insert-dao (apply #'make-instance 'issue attrs)))
|
||||
|
||||
(defun delete-issue (issue)
|
||||
(delete-dao issue))
|
||||
|
||||
(defun set-issue-status (issue-id status)
|
||||
"Set the status of the issue with the given ISSUE-ID to STATUS in the db. If
|
||||
the issue doesn't exist, signals `issue-not-found'"
|
||||
(check-type issue-id integer)
|
||||
(check-type status issue-status)
|
||||
(let ((original-status (query (:select 'status
|
||||
:from 'issues
|
||||
:where (:= 'id issue-id))
|
||||
:single)))
|
||||
(when (zerop (execute (:update 'issues
|
||||
:set 'status (cl-postgres:to-sql-string status)
|
||||
:where (:= 'id issue-id))))
|
||||
(error 'issue-not-found :id issue-id))
|
||||
(record-issue-event
|
||||
issue-id
|
||||
:field 'status
|
||||
:previous-value (string-upcase original-status)
|
||||
:new-value status)
|
||||
(values)))
|
||||
|
||||
(defun update-issue (issue &rest attrs)
|
||||
"Update the fields of ISSUE with the given ATTRS, which is a plist of slot and
|
||||
value, and record events for the updates"
|
||||
(let ((set-fields
|
||||
(iter (for slot in '(subject body))
|
||||
(for new-value = (getf attrs slot))
|
||||
(appending
|
||||
(let ((previous-value (slot-value issue slot)))
|
||||
(when (and new-value (not (equalp
|
||||
new-value
|
||||
previous-value)))
|
||||
(record-issue-event (id issue)
|
||||
:field slot
|
||||
:previous-value previous-value
|
||||
:new-value new-value)
|
||||
(setf (slot-value issue slot) new-value)
|
||||
(list slot new-value)))))))
|
||||
(execute
|
||||
(sql-compile
|
||||
`(:update issues
|
||||
:set ,@set-fields
|
||||
:where (:= id ,(id issue)))))))
|
||||
|
||||
(defun create-issue-comment (&rest attrs &key issue-id &allow-other-keys)
|
||||
"Insert a new issue comment into the database with the given ATTRS and
|
||||
ISSUE-ID, which should be a plist of initforms, and return an instance of
|
||||
`issue-comment'. If no issue exists with `ID' ISSUE-ID, signals
|
||||
`issue-not-found'."
|
||||
(unless (issue-exists-p issue-id)
|
||||
(error 'issue-not-found :id issue-id))
|
||||
(insert-dao (apply #'make-instance 'issue-comment :issue-id issue-id attrs)))
|
||||
|
||||
(defun issue-commenter-dns (issue-id)
|
||||
"Returns a list of all the dns of users who have commented on ISSUE-ID"
|
||||
(query (:select 'author-dn :distinct
|
||||
:from 'issue-comments
|
||||
:where (:= 'issue-id issue-id))
|
||||
:column))
|
||||
|
||||
(defun issue-subscribers (issue-id)
|
||||
"Returns a list of user DNs who should receive notifications for actions taken
|
||||
on ISSUE-ID.
|
||||
|
||||
Currently this is implemented as the author of issue plus all the users who have
|
||||
commented on the issue, but in the future we likely want to also allow
|
||||
explicitly subscribing to / unsubscribing from individual issues."
|
||||
(let ((issue (get-issue issue-id)))
|
||||
(adjoin (author-dn issue)
|
||||
(issue-commenter-dns issue-id)
|
||||
:test #'equal)))
|
||||
|
||||
|
||||
(comment
|
||||
|
||||
(make-instance 'issue :subject "test")
|
||||
|
||||
(with-connection *pg-spec*
|
||||
(create-issue :subject "test"
|
||||
:author-dn "cn=aspen,ou=users,dc=tvl,dc=fyi"))
|
||||
|
||||
(issue-commenter-dns 1)
|
||||
(issue-subscribers 1)
|
||||
|
||||
;; Creating new migrations
|
||||
(setq *migrations-dir* (merge-pathnames "migrations/"))
|
||||
(generate-migration "create-users-table"
|
||||
:documentation "Add a table to store information about users")
|
||||
(load-migrations)
|
||||
|
||||
;; Running migrations
|
||||
(with-connection *pg-spec*
|
||||
(migrate))
|
||||
)
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
(defpackage panettone.util
|
||||
(:nicknames :util)
|
||||
(:use :cl :klatre)
|
||||
(:import-from :alexandria :when-let)
|
||||
(:export
|
||||
:integer-env :add-missing-base64-padding :and-where :and-where*
|
||||
:define-build-time-var :->dir))
|
||||
|
||||
(defpackage panettone.css
|
||||
(:use :cl :lass)
|
||||
(:export :styles))
|
||||
|
||||
(defpackage panettone.inline-markdown
|
||||
(:use :cl)
|
||||
(:import-from :alexandria :define-constant)
|
||||
(:export :render-inline-markdown))
|
||||
|
||||
(defpackage panettone.irc
|
||||
(:nicknames :irc)
|
||||
(:use :cl :usocket)
|
||||
(:export :noping :send-irc-notification))
|
||||
|
||||
(defpackage :panettone.authentication
|
||||
(:nicknames :authn)
|
||||
(:use :cl :panettone.util :klatre)
|
||||
(:import-from :defclass-std :defclass/std)
|
||||
(:import-from :alexandria :when-let :with-gensyms)
|
||||
(:export
|
||||
:*user*
|
||||
:auth-url
|
||||
:fetch-token
|
||||
:user :cn :dn :mail :displayname
|
||||
:find-user-by-dn
|
||||
:initialise-oauth2))
|
||||
|
||||
(defpackage panettone.model
|
||||
(:nicknames :model)
|
||||
(:use :cl :panettone.util :klatre :postmodern :iterate)
|
||||
(:import-from :alexandria :if-let :when-let :define-constant)
|
||||
(:export
|
||||
:prepare-db-connections
|
||||
:migrate
|
||||
:*pg-spec*
|
||||
|
||||
:create-table-if-not-exists
|
||||
|
||||
:user
|
||||
:sub :username :email
|
||||
|
||||
:user-settings
|
||||
:user-dn :enable-email-notifications-p :settings-for-user
|
||||
:update-user-settings :enable-email-notifications
|
||||
|
||||
:issue :issue-comment :issue-event :migration
|
||||
:id :subject :body :author-dn :issue-id :status :created-at :acting-user-dn
|
||||
:field :previous-value :new-value :+issue-statuses+
|
||||
|
||||
:get-issue :issue-exists-p :list-issues :create-issue :set-issue-status
|
||||
:update-issue :delete-issue :issue-not-found :not-found-id
|
||||
|
||||
:issue-events
|
||||
|
||||
:issue-comments :num-comments :create-issue-comment
|
||||
:issue-commenter-dns :issue-subscribers))
|
||||
|
||||
(defpackage panettone.email
|
||||
(:nicknames :email)
|
||||
(:use :cl)
|
||||
(:import-from :alexandria :when-let :when-let*)
|
||||
(:import-from :panettone.model
|
||||
:settings-for-user :enable-email-notifications-p)
|
||||
(:import-from :panettone.authentication
|
||||
:find-user-by-dn :mail :displayname)
|
||||
(:export
|
||||
:*smtp-server* :*smtp-server-port* :*notification-from*
|
||||
:*notification-from-display-name* :*notification-subject-prefix*
|
||||
:notify-user :send-email-notification))
|
||||
|
||||
(defpackage panettone
|
||||
(:use :cl :klatre :easy-routes :iterate
|
||||
:panettone.util
|
||||
:panettone.authentication
|
||||
:panettone.inline-markdown)
|
||||
(:import-from :defclass-std :defclass/std)
|
||||
(:import-from :alexandria :if-let :when-let :switch :alist-hash-table)
|
||||
(:import-from :cl-ppcre :split)
|
||||
(:import-from :bordeaux-threads :make-thread)
|
||||
(:import-from
|
||||
:panettone.model
|
||||
:id :subject :body :author-dn :issue-id :status :created-at
|
||||
:field :previous-value :new-value :acting-user-dn
|
||||
:*pg-spec*)
|
||||
(:import-from :panettone.irc :send-irc-notification)
|
||||
(:shadow :next)
|
||||
(:export :start-panettone :config :main))
|
||||
|
|
@ -1,681 +0,0 @@
|
|||
(in-package :panettone)
|
||||
(declaim (optimize (safety 3)))
|
||||
|
||||
(defvar *cheddar-url* "http://localhost:4238")
|
||||
|
||||
(defgeneric render-markdown (markdown)
|
||||
(:documentation
|
||||
"Render the argument, or the elements of the argument, as markdown, and return
|
||||
the same structure"))
|
||||
|
||||
(defun request-markdown-from-cheddar (input)
|
||||
"Send the CL value INPUT encoded as JSON to cheddar's
|
||||
markdown endpoint and return the decoded response."
|
||||
(let ((s (drakma:http-request
|
||||
(concatenate 'string
|
||||
*cheddar-url*
|
||||
"/markdown")
|
||||
:accept "application/json"
|
||||
:method :post
|
||||
:content-type "application/json"
|
||||
:external-format-out :utf-8
|
||||
:content (json:encode-json-to-string input)
|
||||
:want-stream t)))
|
||||
(setf (flexi-streams:flexi-stream-external-format s) :utf-8)
|
||||
(cl-json:decode-json s)))
|
||||
|
||||
(defmethod render-markdown ((markdown string))
|
||||
(cdr (assoc :markdown
|
||||
(request-markdown-from-cheddar
|
||||
`((markdown . ,markdown))))))
|
||||
|
||||
(defmethod render-markdown ((markdown hash-table))
|
||||
(alist-hash-table
|
||||
(request-markdown-from-cheddar markdown)))
|
||||
|
||||
(defun markdownify-comment-bodies (comments)
|
||||
"Convert the bodies of the given list of comments to markdown in-place using
|
||||
Cheddar, and return nothing"
|
||||
(let ((in (make-hash-table))
|
||||
(comment-table (make-hash-table)))
|
||||
(dolist (comment comments)
|
||||
(when (typep comment 'model:issue-comment)
|
||||
(setf (gethash (id comment) in) (body comment))
|
||||
(setf (gethash (id comment) comment-table) comment)))
|
||||
(let ((res (render-markdown in)))
|
||||
(iter (for (comment-id markdown-body) in-hashtable res)
|
||||
(let ((comment-id (parse-integer (symbol-name comment-id))))
|
||||
(setf (slot-value (gethash comment-id comment-table)
|
||||
'model:body)
|
||||
markdown-body)))))
|
||||
(values))
|
||||
|
||||
;;;
|
||||
;;; Views
|
||||
;;;
|
||||
|
||||
(defvar *title* "Panettone")
|
||||
|
||||
(eval-when (:compile-toplevel :load-toplevel)
|
||||
(setf (who:html-mode) :html5))
|
||||
|
||||
(defun render/nav ()
|
||||
(who:with-html-output (*standard-output*)
|
||||
(:nav
|
||||
(if (find (car (split "\\?" (hunchentoot:request-uri*) :limit 2))
|
||||
(list "/" "/issues/closed")
|
||||
:test #'string=)
|
||||
(who:htm (:span :class "placeholder"))
|
||||
(who:htm (:a :href "/" "All Issues")))
|
||||
(if *user*
|
||||
(who:htm
|
||||
(:div :class "nav-group"
|
||||
(:a :href "/settings" "Settings")
|
||||
(:form :class "form-link log-out"
|
||||
:method "post"
|
||||
:action "/logout"
|
||||
(:input :type "submit" :value "Log Out"))))
|
||||
(who:htm
|
||||
(:a :href
|
||||
(format nil
|
||||
"/auth?original-uri=~A"
|
||||
(drakma:url-encode (hunchentoot:request-uri*)
|
||||
:utf-8))
|
||||
"Log In"))))))
|
||||
|
||||
(defun author (object)
|
||||
(find-user-by-dn (author-dn object)))
|
||||
|
||||
(defun displayname-if-known (user)
|
||||
(or (when user (displayname user))
|
||||
"unknown"))
|
||||
|
||||
(defmacro render ((&key
|
||||
(footer t)
|
||||
(header t))
|
||||
&body body)
|
||||
`(who:with-html-output-to-string (*standard-output* nil :prologue t)
|
||||
(:html
|
||||
:lang "en"
|
||||
(:head
|
||||
(:title (who:esc *title*))
|
||||
(:link :rel "stylesheet" :type "text/css" :href "/main.css")
|
||||
(:meta :name "viewport"
|
||||
:content "width=device-width,initial-scale=1"))
|
||||
(:body
|
||||
(:div
|
||||
:class "content"
|
||||
(when ,header
|
||||
(who:htm
|
||||
(render/nav)))
|
||||
,@body
|
||||
(when ,footer
|
||||
(who:htm
|
||||
(:footer
|
||||
(render/nav)))))))))
|
||||
|
||||
(defun form-button (&key
|
||||
class
|
||||
input-class
|
||||
href
|
||||
label
|
||||
(method "post"))
|
||||
(who:with-html-output (*standard-output*)
|
||||
(:form :class class
|
||||
:method method
|
||||
:action href
|
||||
(:input :type "submit"
|
||||
:class input-class
|
||||
:value label))))
|
||||
|
||||
(defun render/alert (message)
|
||||
"Render an alert box for MESSAGE, if non-null"
|
||||
(check-type message (or null string))
|
||||
(who:with-html-output (*standard-output*)
|
||||
(when message
|
||||
(who:htm (:div :class "alert" (who:esc message))))))
|
||||
|
||||
(defun render/settings ()
|
||||
(let ((settings (model:settings-for-user (dn *user*))))
|
||||
(render ()
|
||||
(:div
|
||||
:class "settings-page"
|
||||
(:header
|
||||
(:h1 "Settings"))
|
||||
(:form
|
||||
:method :post :action "/settings"
|
||||
(:div
|
||||
(:label :class "checkbox"
|
||||
(:input :type "checkbox"
|
||||
:name "enable-email-notifications"
|
||||
:id "enable-email-notifications"
|
||||
:checked (model:enable-email-notifications-p
|
||||
settings))
|
||||
"Enable Email Notifications"))
|
||||
(:div :class "form-group"
|
||||
(:input :type "submit"
|
||||
:value "Save Settings")))))))
|
||||
|
||||
(defun created-by-at (issue)
|
||||
(check-type issue model:issue)
|
||||
(who:with-html-output (*standard-output*)
|
||||
(:span :class "created-by-at"
|
||||
"Opened by "
|
||||
(:span :class "username"
|
||||
(who:esc (displayname-if-known
|
||||
(author issue))))
|
||||
" at "
|
||||
(:span :class "timestamp"
|
||||
(who:esc
|
||||
(format-dottime (created-at issue)))))))
|
||||
|
||||
(defun render/issue-list (&key issues)
|
||||
(who:with-html-output (*standard-output*)
|
||||
(:ol
|
||||
:class "issue-list"
|
||||
(dolist (issue issues)
|
||||
(let ((issue-id (model:id issue)))
|
||||
(who:htm
|
||||
(:li
|
||||
(:a :href (format nil "/issues/~A" issue-id)
|
||||
(:p
|
||||
(:span :class "issue-subject"
|
||||
(render-inline-markdown (subject issue))))
|
||||
(:span :class "issue-number"
|
||||
(who:esc (format nil "#~A" issue-id)))
|
||||
" - "
|
||||
(created-by-at issue)
|
||||
(let ((num-comments (length (model:issue-comments issue))))
|
||||
(unless (zerop num-comments)
|
||||
(who:htm
|
||||
(:span :class "comment-count"
|
||||
" - "
|
||||
(who:esc
|
||||
(format nil "~A comment~:p" num-comments))))))))))))))
|
||||
|
||||
(defun render/issue-search (&key search)
|
||||
(who:with-html-output (*standard-output*)
|
||||
(:form
|
||||
:method "get"
|
||||
:class "issue-search"
|
||||
(:input :type "search"
|
||||
:name "search"
|
||||
:title "Issue search query"
|
||||
:value search)
|
||||
(:input
|
||||
:type "submit"
|
||||
:value "Search Issues"
|
||||
:class "sr-only"))))
|
||||
|
||||
(defun render/index (&key issues search)
|
||||
(render ()
|
||||
(:header
|
||||
(:h1 "Issues")
|
||||
(when *user*
|
||||
(who:htm
|
||||
(:a
|
||||
:class "new-issue"
|
||||
:href "/issues/new" "New Issue"))))
|
||||
(:main
|
||||
(:div
|
||||
:class "issue-links"
|
||||
(:a :href "/issues/closed" "View closed issues")
|
||||
(render/issue-search :search search))
|
||||
(render/issue-list :issues issues))))
|
||||
|
||||
(defun render/closed-issues (&key issues search)
|
||||
(render ()
|
||||
(:header
|
||||
(:h1 "Closed issues"))
|
||||
(:main
|
||||
(:div
|
||||
:class "issue-links"
|
||||
(:a :href "/" "View open isues")
|
||||
(render/issue-search :search search))
|
||||
(render/issue-list :issues issues))))
|
||||
|
||||
(defun render/issue-form (&optional issue message)
|
||||
(let ((editing (and issue (id issue))))
|
||||
(render ()
|
||||
(:header
|
||||
(:h1
|
||||
(who:esc
|
||||
(if editing "Edit Issue" "New Issue"))))
|
||||
(:main
|
||||
(render/alert message)
|
||||
(:form :method "post"
|
||||
:action (if editing
|
||||
(format nil "/issues/~A"
|
||||
(id issue))
|
||||
"/issues")
|
||||
:class "issue-form"
|
||||
(:div
|
||||
(:input :type "text"
|
||||
:id "subject"
|
||||
:name "subject"
|
||||
:placeholder "Subject"
|
||||
:value (when editing
|
||||
(who:escape-string
|
||||
(subject issue)))))
|
||||
|
||||
(:div
|
||||
(:textarea :name "body"
|
||||
:placeholder "Description"
|
||||
:rows 10
|
||||
(who:esc
|
||||
(when editing
|
||||
(body issue)))))
|
||||
|
||||
(:input :type "submit"
|
||||
:value
|
||||
(if editing
|
||||
"Save Issue"
|
||||
"Create Issue")))))))
|
||||
|
||||
(defun render/new-comment (issue-id)
|
||||
(who:with-html-output (*standard-output*)
|
||||
(:form
|
||||
:class "new-comment"
|
||||
:method "post"
|
||||
:action (format nil "/issues/~A/comments" issue-id)
|
||||
(:div
|
||||
(:textarea :name "body"
|
||||
:placeholder "Leave a comment"
|
||||
:rows 5))
|
||||
(:input :type "submit"
|
||||
:value "Comment"))))
|
||||
|
||||
(defgeneric render/issue-history-item (item))
|
||||
|
||||
(defmethod render/issue-history-item ((comment model:issue-comment))
|
||||
(let ((fragment (format nil "comment-~A" (id comment))))
|
||||
(who:with-html-output (*standard-output*)
|
||||
(:li
|
||||
:class "comment"
|
||||
:id fragment
|
||||
(:p (who:str (body comment)))
|
||||
(:p
|
||||
:class "comment-info"
|
||||
(:span :class "username"
|
||||
(who:esc
|
||||
(displayname-if-known (author comment)))
|
||||
" at "
|
||||
(:a :href (concatenate 'string "#" fragment)
|
||||
(who:esc (format-dottime (created-at comment))))))))))
|
||||
|
||||
(defmethod render/issue-history-item ((event model:issue-event))
|
||||
(let ((user (find-user-by-dn (acting-user-dn event)))
|
||||
(fragment (format nil "event-~A" (id event))))
|
||||
(who:with-html-output (*standard-output*)
|
||||
(:li
|
||||
:class "event"
|
||||
:id fragment
|
||||
(who:esc (displayname-if-known user))
|
||||
(switch ((field event) :test #'string=)
|
||||
("STATUS"
|
||||
(who:htm
|
||||
(who:esc
|
||||
(switch ((new-value event) :test #'string=)
|
||||
("OPEN" " reopened ")
|
||||
("CLOSED" " closed ")))
|
||||
" this issue "))
|
||||
("BODY" (who:htm " updated the body of this issue"))
|
||||
(t
|
||||
(who:htm
|
||||
" changed the "
|
||||
(who:esc (string-downcase (field event)))
|
||||
" of this issue from \""
|
||||
(who:esc (previous-value event))
|
||||
"\" to \""
|
||||
(who:esc (new-value event))
|
||||
"\"")))
|
||||
" at "
|
||||
(who:esc (format-dottime (created-at event)))))))
|
||||
|
||||
(defun render/issue (issue)
|
||||
(check-type issue model:issue)
|
||||
(let ((issue-id (id issue))
|
||||
(issue-status (status issue)))
|
||||
(render ()
|
||||
(:header
|
||||
(:h1 (render-inline-markdown (subject issue)))
|
||||
(:div :class "issue-number"
|
||||
(who:esc (format nil "#~A" issue-id))))
|
||||
(:main
|
||||
(:div
|
||||
:class "issue-info"
|
||||
(created-by-at issue)
|
||||
|
||||
(when *user*
|
||||
(who:htm
|
||||
(when (string= (author-dn issue)
|
||||
(dn *user*))
|
||||
(who:htm
|
||||
(:a :class "edit-issue"
|
||||
:href (format nil "/issues/~A/edit"
|
||||
issue-id)
|
||||
"Edit")))
|
||||
(form-button
|
||||
:class "set-issue-status"
|
||||
:href (format nil "/issues/~A/~A"
|
||||
issue-id
|
||||
(case issue-status
|
||||
(:open "close")
|
||||
(:closed "open")))
|
||||
:input-class (case issue-status
|
||||
(:open "close-issue")
|
||||
(:closed "open-issue"))
|
||||
:label (case issue-status
|
||||
(:open "Close")
|
||||
(:closed "Reopen"))))))
|
||||
(:p (who:str (render-markdown (body issue))))
|
||||
(let* ((comments (model:issue-comments issue))
|
||||
(events (model:issue-events issue))
|
||||
(history (merge 'list
|
||||
comments
|
||||
events
|
||||
#'local-time:timestamp<
|
||||
:key #'created-at)))
|
||||
(markdownify-comment-bodies comments)
|
||||
(when (or history *user*)
|
||||
(who:htm
|
||||
(:ol
|
||||
:class "issue-history"
|
||||
(dolist (item history)
|
||||
(render/issue-history-item item))
|
||||
(when *user*
|
||||
(render/new-comment (id issue)))))))))))
|
||||
|
||||
(defun render/not-found (entity-type)
|
||||
(render ()
|
||||
(:h1 (who:esc entity-type) " Not Found")))
|
||||
|
||||
;;;
|
||||
;;; HTTP handlers
|
||||
;;;
|
||||
|
||||
(defun send-email-for-issue
|
||||
(issue-id &key subject (message ""))
|
||||
"Send an email notification to all subscribers to the given issue with the
|
||||
given subject an body (in a thread, to avoid blocking)"
|
||||
(let ((current-user *user*))
|
||||
(bordeaux-threads:make-thread
|
||||
(lambda ()
|
||||
(pomo:with-connection *pg-spec*
|
||||
(dolist (user-dn (model:issue-subscribers issue-id))
|
||||
(when (not (equal (dn current-user) user-dn))
|
||||
(email:notify-user
|
||||
user-dn
|
||||
:subject subject
|
||||
:message message))))))))
|
||||
|
||||
(defun link-to-issue (issue-id)
|
||||
(format nil "https://b.tvl.fyi/issues/~A" issue-id))
|
||||
|
||||
(defun @auth-optional (next)
|
||||
(let ((*user* (hunchentoot:session-value 'user)))
|
||||
(funcall next)))
|
||||
|
||||
(defun @auth (next)
|
||||
(if-let ((*user* (hunchentoot:session-value 'user)))
|
||||
(funcall next)
|
||||
(hunchentoot:redirect
|
||||
(format nil "/auth?original-uri=~A"
|
||||
(drakma:url-encode
|
||||
(hunchentoot:request-uri*)
|
||||
:utf-8)))))
|
||||
|
||||
(defun @db (next)
|
||||
"Decorator for handlers that use the database, wrapped in a transaction."
|
||||
(pomo:with-connection *pg-spec*
|
||||
(pomo:with-transaction ()
|
||||
(catch
|
||||
;; 'hunchentoot:handler-done is unexported, but is used by functions
|
||||
;; like hunchentoot:redirect to nonlocally abort the request handler -
|
||||
;; this doesn't mean an error occurred, so we need to catch it here to
|
||||
;; make the transaction still get committed
|
||||
(intern "HANDLER-DONE" "HUNCHENTOOT")
|
||||
(funcall next)))))
|
||||
|
||||
(defun @handle-issue-not-found (next)
|
||||
(handler-case (funcall next)
|
||||
(model:issue-not-found (err)
|
||||
(render/not-found
|
||||
(format nil "Issue #~A" (model:not-found-id err))))))
|
||||
|
||||
(defroute auth-handler ("/auth" :method :get :decorators (@auth-optional)) ()
|
||||
(if-let ((code (hunchentoot:get-parameter "code")))
|
||||
(let ((user (fetch-token code)))
|
||||
(setf (hunchentoot:session-value 'user) user)
|
||||
(hunchentoot:redirect (or (hunchentoot:session-value 'original-uri) "/")))
|
||||
|
||||
(progn
|
||||
(when-let ((original-uri (hunchentoot:get-parameter "original-uri")))
|
||||
(setf (hunchentoot:session-value 'original-uri) original-uri))
|
||||
(hunchentoot:redirect (authn:auth-url)))))
|
||||
|
||||
(defroute logout ("/logout" :method :post) ()
|
||||
(hunchentoot:delete-session-value 'user)
|
||||
(hunchentoot:redirect "/"))
|
||||
|
||||
(defroute index ("/" :decorators (@auth-optional @db)) (&get search)
|
||||
(let ((issues (model:list-issues :status :open
|
||||
:search search)))
|
||||
(render/index :issues issues
|
||||
:search search)))
|
||||
|
||||
(defroute settings ("/settings" :method :get :decorators (@auth @db)) ()
|
||||
(render/settings))
|
||||
|
||||
(defroute save-settings ("/settings" :method :post :decorators (@auth @db))
|
||||
(&post enable-email-notifications)
|
||||
(let ((settings (model:settings-for-user (dn *user*))))
|
||||
(model:update-user-settings
|
||||
settings
|
||||
'model:enable-email-notifications enable-email-notifications)
|
||||
(render/settings)))
|
||||
|
||||
(defroute handle-closed-issues
|
||||
("/issues/closed" :decorators (@auth-optional @db))
|
||||
(&get search)
|
||||
(let ((issues (model:list-issues :status :closed
|
||||
:search search)))
|
||||
(render/closed-issues :issues issues
|
||||
:search search)))
|
||||
|
||||
(defroute new-issue ("/issues/new" :decorators (@auth)) ()
|
||||
(render/issue-form))
|
||||
|
||||
(defroute handle-create-issue
|
||||
("/issues" :method :post :decorators (@auth @db))
|
||||
(&post subject body)
|
||||
(if (string= subject "")
|
||||
(render/issue-form
|
||||
(make-instance 'model:issue :subject subject :body body)
|
||||
"Subject is required")
|
||||
(let ((issue
|
||||
(model:create-issue :subject subject
|
||||
:body body
|
||||
:author-dn (dn *user*))))
|
||||
(send-irc-notification
|
||||
(format nil
|
||||
"b/~A: \"~A\" opened by ~A - https://b.tvl.fyi/issues/~A"
|
||||
(id issue)
|
||||
subject
|
||||
(irc:noping (cn *user*))
|
||||
(id issue))
|
||||
:channel (or (uiop:getenvp "ISSUECHANNEL")
|
||||
"#tvl"))
|
||||
(hunchentoot:redirect
|
||||
(format nil "/issues/~A" (id issue))))))
|
||||
|
||||
(defroute show-issue
|
||||
("/issues/:id" :decorators (@auth-optional @handle-issue-not-found @db))
|
||||
(&path (id 'integer))
|
||||
(let* ((issue (model:get-issue id))
|
||||
(*title* (format nil "~A | Panettone"
|
||||
(subject issue))))
|
||||
(render/issue issue)))
|
||||
|
||||
(defroute edit-issue
|
||||
("/issues/:id/edit" :decorators (@auth @handle-issue-not-found @db))
|
||||
(&path (id 'integer))
|
||||
(let* ((issue (model:get-issue id))
|
||||
(*title* "Edit Issue | Panettone"))
|
||||
(render/issue-form issue)))
|
||||
|
||||
(defroute update-issue
|
||||
("/issues/:id" :decorators (@auth @handle-issue-not-found @db)
|
||||
;; NOTE: this should be a put, but we're all HTML forms
|
||||
;; right now and those don't support PUT
|
||||
:method :post)
|
||||
(&path (id 'integer) &post subject body)
|
||||
(let ((issue (model:get-issue id)))
|
||||
;; only the original author can edit an issue
|
||||
(if (string-equal (author-dn issue)
|
||||
(dn *user*))
|
||||
(progn
|
||||
(model:update-issue issue
|
||||
'model:subject subject
|
||||
'model:body body)
|
||||
(hunchentoot:redirect (format nil "/issues/~A" id)))
|
||||
(render/not-found "Issue"))))
|
||||
|
||||
(defroute handle-create-comment
|
||||
("/issues/:id/comments"
|
||||
:decorators (@auth @handle-issue-not-found @db)
|
||||
:method :post)
|
||||
(&path (id 'integer) &post body)
|
||||
(flet ((redirect-to-issue ()
|
||||
(hunchentoot:redirect (format nil "/issues/~A" id))))
|
||||
(cond
|
||||
((string= body "")
|
||||
(redirect-to-issue))
|
||||
(:else
|
||||
(model:create-issue-comment
|
||||
:issue-id id
|
||||
:body body
|
||||
:author-dn (dn *user*))
|
||||
|
||||
(let ((issue (model:get-issue id)))
|
||||
(send-email-for-issue
|
||||
id
|
||||
:subject (format nil "~A commented on b/~A: \"~A\""
|
||||
(displayname *user*)
|
||||
id
|
||||
(subject issue))
|
||||
:message (format nil "~A~%~%~A"
|
||||
body
|
||||
(link-to-issue id))))
|
||||
(redirect-to-issue)))))
|
||||
|
||||
(defroute close-issue
|
||||
("/issues/:id/close" :decorators (@auth @handle-issue-not-found @db)
|
||||
:method :post)
|
||||
(&path (id 'integer))
|
||||
(model:set-issue-status id :closed)
|
||||
(let ((issue (model:get-issue id)))
|
||||
(send-irc-notification
|
||||
(format nil
|
||||
"b/~A: \"~A\" closed by ~A - ~A"
|
||||
id
|
||||
(subject issue)
|
||||
(irc:noping (cn *user*))
|
||||
(link-to-issue id))
|
||||
:channel (or (uiop:getenvp "ISSUECHANNEL")
|
||||
"#tvl"))
|
||||
(send-email-for-issue
|
||||
id
|
||||
:subject (format nil "b/~A: \"~A\" closed by ~A"
|
||||
id
|
||||
(subject issue)
|
||||
(displayname *user*))
|
||||
:message (link-to-issue id)))
|
||||
(hunchentoot:redirect (format nil "/issues/~A" id)))
|
||||
|
||||
(defroute open-issue
|
||||
("/issues/:id/open" :decorators (@auth @db)
|
||||
:method :post)
|
||||
(&path (id 'integer))
|
||||
(model:set-issue-status id :open)
|
||||
(let ((issue (model:get-issue id)))
|
||||
(send-irc-notification
|
||||
(format nil
|
||||
"b/~A: \"~A\" reopened by ~A - ~A"
|
||||
id
|
||||
(subject issue)
|
||||
(irc:noping (cn *user*))
|
||||
(link-to-issue id))
|
||||
:channel (or (uiop:getenvp "ISSUECHANNEL")
|
||||
"#tvl"))
|
||||
(send-email-for-issue
|
||||
id
|
||||
:subject (format nil "b/~A: \"~A\" reopened by ~A"
|
||||
id
|
||||
(subject issue)
|
||||
(displayname *user*))
|
||||
:message (link-to-issue id)))
|
||||
(hunchentoot:redirect (format nil "/issues/~A" id)))
|
||||
|
||||
(defroute styles ("/main.css") ()
|
||||
(setf (hunchentoot:content-type*) "text/css")
|
||||
(apply #'lass:compile-and-write panettone.css:styles))
|
||||
|
||||
(defvar *acceptor* nil
|
||||
"Hunchentoot acceptor for Panettone's web server.")
|
||||
|
||||
(defun migrate-db ()
|
||||
"Migrate the database to the latest version of the schema"
|
||||
(pomo:with-connection *pg-spec*
|
||||
(model:migrate)))
|
||||
|
||||
(define-build-time-var *static-dir* "static/"
|
||||
"Directory to serve static files from")
|
||||
|
||||
(defun start-panettone (&key port session-secret)
|
||||
(authn:initialise-oauth2)
|
||||
(model:prepare-db-connections)
|
||||
(migrate-db)
|
||||
|
||||
(when session-secret
|
||||
(setq hunchentoot:*session-secret* session-secret))
|
||||
|
||||
(setq hunchentoot:*session-max-time* (* 60 60 24 90))
|
||||
|
||||
(setq *acceptor*
|
||||
(make-instance 'easy-routes:easy-routes-acceptor :port port))
|
||||
|
||||
(push
|
||||
(hunchentoot:create-folder-dispatcher-and-handler
|
||||
"/static/"
|
||||
(util:->dir *static-dir*))
|
||||
hunchentoot:*dispatch-table*)
|
||||
|
||||
(hunchentoot:start *acceptor*))
|
||||
|
||||
(defun main ()
|
||||
(let ((port (integer-env "PANETTONE_PORT" :default 6161))
|
||||
(cheddar-url (uiop:getenvp "CHEDDAR_URL"))
|
||||
(session-secret (uiop:getenvp "SESSION_SECRET")))
|
||||
(when cheddar-url (setq *cheddar-url* cheddar-url))
|
||||
(setq hunchentoot:*show-lisp-backtraces-p* nil)
|
||||
(setq hunchentoot:*log-lisp-backtraces-p* nil)
|
||||
|
||||
(start-panettone :port port
|
||||
:session-secret session-secret)
|
||||
|
||||
(format t "launched panettone on port ~A~%" port)
|
||||
|
||||
(sb-thread:join-thread
|
||||
(find-if (lambda (th)
|
||||
(string= (sb-thread:thread-name th)
|
||||
(format nil "hunchentoot-listener-*:~A" port)))
|
||||
(sb-thread:list-all-threads)))))
|
||||
|
||||
(comment
|
||||
(setq hunchentoot:*catch-errors-p* nil)
|
||||
;; to setup an ssh tunnel to cheddar+irccat for development:
|
||||
;; ssh -N -L 4238:localhost:4238 -L 4722:localhost:4722 nevsky.tvl.fyi
|
||||
(start-panettone :port 6161
|
||||
:session-secret "session-secret")
|
||||
)
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 711 B |
|
|
@ -1,39 +0,0 @@
|
|||
(in-package :panettone.util)
|
||||
|
||||
(defun integer-env (var &key default)
|
||||
(or
|
||||
(when-let ((str (uiop:getenvp var)))
|
||||
(try-parse-integer str))
|
||||
default))
|
||||
|
||||
(defun add-missing-base64-padding (s)
|
||||
"Add any missing padding characters to the (un-padded) base64 string `S', such
|
||||
that it can be successfully decoded by the `BASE64' package"
|
||||
;; I apologize
|
||||
(let* ((needed-padding (mod (length s) 4))
|
||||
(pad-chars (if (zerop needed-padding) 0 (- 4 needed-padding))))
|
||||
(format nil "~A~v@{~A~:*~}" s pad-chars "=")))
|
||||
|
||||
(defun and-where (clauses)
|
||||
"Combine all non-nil clauses in CLAUSES into a single S-SQL WHERE form"
|
||||
(let ((clauses (remove nil clauses)))
|
||||
(if (null clauses) t
|
||||
(reduce (lambda (x y) `(:and ,x ,y)) clauses))))
|
||||
|
||||
(defun and-where* (&rest clauses)
|
||||
"Combine all non-nil clauses in CLAUSES into a single S-SQL WHERE form"
|
||||
(and-where clauses))
|
||||
|
||||
(defmacro define-build-time-var
|
||||
(name value-if-not-in-build &optional (doc nil))
|
||||
`(defvar ,name
|
||||
(or (when-let ((package (find-package :build)))
|
||||
(let ((sym (find-symbol ,(symbol-name name) package)))
|
||||
(when (boundp sym) (symbol-value sym))))
|
||||
,value-if-not-in-build)
|
||||
,doc))
|
||||
|
||||
(defun ->dir (dir)
|
||||
(if (char-equal (uiop:last-char dir) #\/)
|
||||
dir
|
||||
(concatenate 'string dir "/")))
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
(in-package :panettone.tests)
|
||||
(declaim (optimize (safety 3)))
|
||||
|
||||
(defmacro inline-markdown-unit-test (name input expected)
|
||||
`(test ,name
|
||||
(is (equal
|
||||
,expected
|
||||
(with-output-to-string (*standard-output*)
|
||||
(render-inline-markdown ,input))))))
|
||||
|
||||
(inline-markdown-unit-test
|
||||
inline-markdown-typical-test
|
||||
"hello *world*, here is ~~no~~ `code`!"
|
||||
"hello <em>world</em>, here is <del>no</del> <code>code</code>!")
|
||||
|
||||
(inline-markdown-unit-test
|
||||
inline-markdown-two-emphasize-types-test
|
||||
"*stress* *this*"
|
||||
"<em>stress</em> <em>this</em>")
|
||||
|
||||
(inline-markdown-unit-test
|
||||
inline-markdown-html-escaping-test
|
||||
"<tag>öäü"
|
||||
"<tag>öäü")
|
||||
|
||||
(inline-markdown-unit-test
|
||||
inline-markdown-nesting-test
|
||||
"`inside code *anything* goes`, but also ~~*here*~~"
|
||||
"<code>inside code *anything* goes</code>, but also <del>*here*</del>")
|
||||
|
||||
(inline-markdown-unit-test
|
||||
inline-markdown-escaping-test
|
||||
"A backslash \\\\ shows: \\*, \\` and \\~~"
|
||||
"A backslash \\ shows: *, ` and ~~")
|
||||
|
||||
(inline-markdown-unit-test
|
||||
inline-markdown-nested-escaping-test
|
||||
"`prevent \\`code\\` from ending, but never stand alone \\\\`"
|
||||
"<code>prevent `code` from ending, but never stand alone \\</code>")
|
||||
|
||||
(inline-markdown-unit-test
|
||||
inline-markdown-escape-normal-tokens-test
|
||||
"\\Normal tokens \\escaped?"
|
||||
"\\Normal tokens \\escaped?")
|
||||
|
||||
(inline-markdown-unit-test
|
||||
inline-markdown-no-unclosed-tags-test
|
||||
"A tag, once opened, *must be closed"
|
||||
"A tag, once opened, <em>must be closed</em>")
|
||||
|
||||
(inline-markdown-unit-test
|
||||
inline-markdown-unicode-safe
|
||||
"Does Unicode 👨👨👧👦 break \\👩🏾🦰 tokenization?"
|
||||
"Does Unicode 👨‍👨‍👧‍👦 break \\👩🏾‍🦰 tokenization?")
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
(in-package :panettone.tests)
|
||||
(declaim (optimize (safety 3)))
|
||||
|
||||
(test noping-test
|
||||
(is (not (equal "grfn" (panettone.irc:noping "grfn")))))
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
(in-package :panettone.tests)
|
||||
(declaim (optimize (safety 3)))
|
||||
|
||||
(test initialize-issue-status-test
|
||||
(let ((issue (make-instance 'model:issue :status "open")))
|
||||
(is (eq :open (model:status issue)))))
|
||||
|
||||
(test initialize-issue-created-at-test
|
||||
(let* ((time (get-universal-time))
|
||||
(issue (make-instance 'model:issue :created-at time)))
|
||||
(is (local-time:timestamp=
|
||||
(local-time:universal-to-timestamp time)
|
||||
(model:created-at issue)))))
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
(defpackage :panettone.tests
|
||||
(:use :cl :klatre :fiveam
|
||||
:panettone.inline-markdown))
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
(in-package :panettone.tests)
|
||||
(declaim (optimize (safety 3)))
|
||||
|
||||
(test add-missing-base64-padding-test
|
||||
(is (string=
|
||||
"abcdef"
|
||||
(base64:base64-string-to-string
|
||||
(panettone.util:add-missing-base64-padding
|
||||
"YWJjZGVm")))))
|
||||
2
web/planet-mars/.gitignore
vendored
2
web/planet-mars/.gitignore
vendored
|
|
@ -1,2 +0,0 @@
|
|||
/target
|
||||
/mars.toml
|
||||
2085
web/planet-mars/Cargo.lock
generated
2085
web/planet-mars/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,25 +0,0 @@
|
|||
[package]
|
||||
name = "planet-mars"
|
||||
version = "0.1.1"
|
||||
edition = "2021"
|
||||
authors = ["Thomas Koch <thomas@koch.ro>"]
|
||||
description = "Feed aggregation planet like Planet Venus, produces static HTML and ATOM feed from fetched feeds."
|
||||
homepage = "https://github.com/thkoch2001/planet-mars"
|
||||
license = "AGPL-3.0-or-later"
|
||||
keywords = ["atom", "rss", "planet", "feed", "blogging"]
|
||||
categories = ["web-programming"]
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
env_logger = "0"
|
||||
feed-rs = "2"
|
||||
log = "0"
|
||||
ron = "0"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
slug = "0"
|
||||
tera = "1"
|
||||
toml = "0"
|
||||
ureq = { version = "3.0.0-rc5", features = ["brotli", "charset", "gzip", "native-tls"]}
|
||||
url = "2"
|
||||
|
||||
|
|
@ -1 +0,0 @@
|
|||
thk
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
Simple successor to Planet Venus but in Rust and maintained.
|
||||
|
||||
Please see the rustdoc of main.rs for further information.
|
||||
|
||||
## Todo
|
||||
|
||||
* find and use a nice lib to process the config file
|
||||
* should check whether dirs exists and are writeable
|
||||
* should check whether feed urls can be parsed
|
||||
|
||||
## Planet Venus
|
||||
|
||||
Planet Venus is used by many planets on the internet. However its code has not
|
||||
been maintained since ~2011 and it uses Python 2.
|
||||
|
||||
Planet Mars should be a lightweight successor to Planet Venus.
|
||||
|
||||
Still the Planet Venus documentation contains some useful information on
|
||||
[Etiquette](https://intertwingly.net/code/venus/docs/etiquette.html) for
|
||||
Planet hosters.
|
||||
|
||||
## Credits
|
||||
|
||||
While writing this, I read and also copied code from:
|
||||
|
||||
* [agro](https://docs.rs/crate/agro/0.1.1)
|
||||
* [hades](https://github.com/kitallis/hades)
|
||||
* [planetrs](https://github.com/djc/planetrs)
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue