commit 115e78dc583f6762b4f93e0794223059b9df42b4 Author: Snídaňový Mistr Date: Sat Mar 16 12:16:46 2024 +0100 Žijí v mých zdech diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..0c845ce --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/pages/*.html +/target +/templates_min +/uploads +Nekrochan.toml +.env diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..20f4884 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "html.validate.styles": false +} diff --git a/Cargo.lock b/Cargo.lock new file mode 100755 index 0000000..d57f497 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3411 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "actix" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb72882332b6d6282f428b77ba0358cb2687e61a6f6df6a6d3871e8a177c2d4f" +dependencies = [ + "actix-macros", + "actix-rt", + "actix_derive", + "bitflags 2.3.3", + "bytes", + "crossbeam-channel", + "futures-core", + "futures-sink", + "futures-task", + "futures-util", + "log", + "once_cell", + "parking_lot", + "pin-project-lite", + "smallvec", + "tokio", + "tokio-util", +] + +[[package]] +name = "actix-codec" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617a8268e3537fe1d8c9ead925fca49ef6400927ee7bc26750e90ecee14ce4b8" +dependencies = [ + "bitflags 1.3.2", + "bytes", + "futures-core", + "futures-sink", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "actix-files" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d832782fac6ca7369a70c9ee9a20554623c5e51c76e190ad151780ebea1cf689" +dependencies = [ + "actix-http", + "actix-service", + "actix-utils", + "actix-web", + "askama_escape", + "bitflags 1.3.2", + "bytes", + "derive_more", + "futures-core", + "http-range", + "log", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", +] + +[[package]] +name = "actix-http" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2079246596c18b4a33e274ae10c0e50613f4d32a4198e09c7b93771013fed74" +dependencies = [ + "actix-codec", + "actix-rt", + "actix-service", + "actix-utils", + "ahash 0.8.3", + "base64 0.21.2", + "bitflags 1.3.2", + "brotli", + "bytes", + "bytestring", + "derive_more", + "encoding_rs", + "flate2", + "futures-core", + "h2", + "http", + "httparse", + "httpdate", + "itoa", + "language-tags", + "local-channel", + "mime", + "percent-encoding", + "pin-project-lite", + "rand", + "sha1", + "smallvec", + "tokio", + "tokio-util", + "tracing", + "zstd", +] + +[[package]] +name = "actix-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb" +dependencies = [ + "quote", + "syn 2.0.28", +] + +[[package]] +name = "actix-multipart" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dee489e3c01eae4d1c35b03c4493f71cb40d93f66b14558feb1b1a807671cc4e" +dependencies = [ + "actix-multipart-derive", + "actix-utils", + "actix-web", + "bytes", + "derive_more", + "futures-core", + "futures-util", + "httparse", + "local-waker", + "log", + "memchr", + "mime", + "serde", + "serde_json", + "serde_plain", + "tempfile", + "tokio", +] + +[[package]] +name = "actix-multipart-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ec592f234db8a253cf80531246a4407c8a70530423eea80688a6c5a44a110e7" +dependencies = [ + "darling", + "parse-size", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "actix-router" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66ff4d247d2b160861fa2866457e85706833527840e4133f8f49aa423a38799" +dependencies = [ + "bytestring", + "http", + "regex", + "serde", + "tracing", +] + +[[package]] +name = "actix-rt" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15265b6b8e2347670eb363c47fc8c75208b4a4994b27192f345fcbe707804f3e" +dependencies = [ + "futures-core", + "tokio", +] + +[[package]] +name = "actix-server" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e8613a75dd50cc45f473cee3c34d59ed677c0f7b44480ce3b8247d7dc519327" +dependencies = [ + "actix-rt", + "actix-service", + "actix-utils", + "futures-core", + "futures-util", + "mio", + "num_cpus", + "socket2 0.4.9", + "tokio", + "tracing", +] + +[[package]] +name = "actix-service" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b894941f818cfdc7ccc4b9e60fa7e53b5042a2e8567270f9147d5591893373a" +dependencies = [ + "futures-core", + "paste", + "pin-project-lite", +] + +[[package]] +name = "actix-utils" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a1dcdff1466e3c2488e1cb5c36a71822750ad43839937f85d2f4d9f8b705d8" +dependencies = [ + "local-waker", + "pin-project-lite", +] + +[[package]] +name = "actix-web" +version = "4.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd3cb42f9566ab176e1ef0b8b3a896529062b4efc6be0123046095914c4c1c96" +dependencies = [ + "actix-codec", + "actix-http", + "actix-macros", + "actix-router", + "actix-rt", + "actix-server", + "actix-service", + "actix-utils", + "actix-web-codegen", + "ahash 0.7.6", + "bytes", + "bytestring", + "cfg-if", + "cookie", + "derive_more", + "encoding_rs", + "futures-core", + "futures-util", + "http", + "itoa", + "language-tags", + "log", + "mime", + "once_cell", + "pin-project-lite", + "regex", + "serde", + "serde_json", + "serde_urlencoded", + "smallvec", + "socket2 0.4.9", + "time", + "url", +] + +[[package]] +name = "actix-web-actors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "420b001bb709d8510c3e2659dae046e54509ff9528018d09c78381e765a1f9fa" +dependencies = [ + "actix", + "actix-codec", + "actix-http", + "actix-web", + "bytes", + "bytestring", + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "actix-web-codegen" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2262160a7ae29e3415554a3f1fc04c764b1540c116aa524683208078b7a75bc9" +dependencies = [ + "actix-router", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "actix_derive" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c7db3d5a9718568e4cf4a537cfd7070e6e6ff7481510d0237fb529ac850f6d3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.28", +] + +[[package]] +name = "addr2line" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4fa78e18c64fce05e902adecd7a5eed15a5e0a3439f7b0e169f0252214865e3" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] +name = "ahash" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcb51a0695d8f838b1ee009b3fbf66bda078cd64590202a864a8f3e8c4315c47" +dependencies = [ + "getrandom", + "once_cell", + "version_check", +] + +[[package]] +name = "ahash" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c99f64d1e06488f620f932677e24bc6e2897582980441ae90a671415bd7ec2f" +dependencies = [ + "cfg-if", + "getrandom", + "once_cell", + "version_check", +] + +[[package]] +name = "aho-corasick" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43f6cb1bf222025340178f382c426f13757b2960e89779dfcb319c32542a5a41" +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 = "allocator-api2" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56fc6cf8dc8c4158eed8649f9b8b0ea1518eb62b544fe9490d66fa0b349eafe9" + +[[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 = "anstream" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e1ebcb11de5c03c67de28a7df593d32191b44939c482e97702baaaa6ab6a5" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8901269c6307e8d93993578286ac0edf7f195079ffff5ebdeea6a59ffb7e36bc" + +[[package]] +name = "anstyle-parse" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c75ac65da39e5fe5ab759307499ddad880d724eed2f6ce5b5e8a26f4f387928c" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e28923312444cdd728e4738b3f9c9cac739500909bb3d3c94b43551b16517648" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cd54b81ec8d6180e24654d0b371ad22fc3dd083b6ff8ba325b72e00c87660a7" +dependencies = [ + "anstyle", + "windows-sys 0.52.0", +] + +[[package]] +name = "anyhow" +version = "1.0.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c6f84b74db2535ebae81eede2f39b947dcbf01d093ae5f791e5dd414a1bf289" + +[[package]] +name = "askama" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47cbc3cf73fa8d9833727bbee4835ba5c421a0d65b72daf9a7b5d0e0f9cfb57e" +dependencies = [ + "askama_derive", + "askama_escape", + "humansize", + "num-traits", + "percent-encoding", +] + +[[package]] +name = "askama_derive" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c22fbe0413545c098358e56966ff22cdd039e10215ae213cfbd65032b119fc94" +dependencies = [ + "basic-toml", + "mime", + "mime_guess", + "nom", + "proc-macro2", + "quote", + "serde", + "syn 2.0.28", +] + +[[package]] +name = "askama_escape" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "619743e34b5ba4e9703bba34deac3427c72507c7159f5fd030aea8cac0cfe341" + +[[package]] +name = "async-trait" +version = "0.1.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a66537f1bb974b254c98ed142ff995236e81b9d0fe4db0575f46612cb15eb0f9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.28", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "backtrace" +version = "0.3.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4319208da049c43661739c5fade2ba182f09d1dc2299b32298d3a31692b17e12" +dependencies = [ + "addr2line", + "cc", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", +] + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "604178f6c5c21f02dc555784810edfb88d34ac2c73b2eae109655649ee73ce3d" + +[[package]] +name = "base64ct" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" + +[[package]] +name = "basic-toml" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f838d03a705d72b12389b8930bd14cacf493be1380bfb15720d4d12db5ab03ac" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "630be753d4e58660abd17930c71b647fe46c27ea6b63cc59e1e3851406972e42" +dependencies = [ + "serde", +] + +[[package]] +name = "block-buffer" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blowfish" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32fa6a061124e37baba002e496d203e23ba3d7b73750be82dbfbc92913048a5b" +dependencies = [ + "byteorder", + "cipher", + "opaque-debug", +] + +[[package]] +name = "brotli" +version = "3.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a0b1dbcc8ae29329621f8d4f0d835787c1c38bb1401979b49d13b0b305ff68" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "2.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b6561fd3f895a11e8f72af2cb7d22e08366bebc2b6b57f7744c4bda27034744" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e2c3daef883ecc1b5d58c15adae93470a91d425f3532ba1695849656af3fc1" + +[[package]] +name = "bytemuck" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17febce684fd15d89027105661fec94afb475cb995fbc59d2865198446ba2eea" + +[[package]] +name = "byteorder" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" + +[[package]] +name = "bytes" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89b2fd2a0dcf38d7971e2194b6b6eebab45ae01067456a7fd93d5547a61b70be" + +[[package]] +name = "bytestring" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "238e4886760d98c4f899360c834fa93e62cf7f721ac3c2da375cbdf4b8679aae" +dependencies = [ + "bytes", +] + +[[package]] +name = "captcha" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db21780337b425f968a2c3efa842eeaa4fe53d2bcb1eb27d2877460a862fb0ab" +dependencies = [ + "base64 0.13.1", + "hound", + "image", + "lodepng", + "rand", + "serde_json", +] + +[[package]] +name = "cc" +version = "1.0.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0" +dependencies = [ + "jobserver", + "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", + "js-sys", + "num-traits", + "pure-rust-locales", + "serde", + "wasm-bindgen", + "windows-targets 0.48.1", +] + +[[package]] +name = "chrono-tz" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91d7b79e99bfaa0d47da0687c43aa3b7381938a62ad3a6498599039321f660b7" +dependencies = [ + "chrono", + "chrono-tz-build", + "phf", +] + +[[package]] +name = "chrono-tz-build" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "433e39f13c9a060046954e0592a8d0a4bcb1040125cbf91cb8ee58964cfb350f" +dependencies = [ + "parse-zoneinfo", + "phf", + "phf_codegen", +] + +[[package]] +name = "cipher" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f8e7987cbd042a63249497f41aed09f8e65add917ea6566effbc56578d6801" +dependencies = [ + "generic-array", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "colorchoice" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" + +[[package]] +name = "combine" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35ed6e9d84f0b51a7f52daf1c7d71dd136fd7a3f41a8462b8cdb8c78d920fad4" +dependencies = [ + "bytes", + "futures-core", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "const-oid" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6340df57935414636969091153f35f68d9f00bbc8fb4a9c6054706c213e6c6bc" + +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + +[[package]] +name = "cookie" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa" + +[[package]] +name = "cow-utils" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79bb3adfaf5f75d24b01aee375f7555907840fa2800e5ec8fa3b9e2031830173" + +[[package]] +name = "cpufeatures" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a17b76ff3a4162b0b27f354a0c87015ddad39d35f9c0c36607a3bdd175dde1f1" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86ec7a15cbe22e59248fc7eadb1907dab5ba09372595da4d73dd805ed4417dfe" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cace84e55f07e7301bae1c519df89cdad8cc3cd868413d3fdbdeca9ff3db484" + +[[package]] +name = "crc32fast" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "176dc175b78f56c0f321911d9c8eb2b77a78a4860b9c19db83835fea1a46649b" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1cfb3ea8a53f37c40dea2c7bedcbd88bdfae54f5e2175d6ecaff1c988353add" +dependencies = [ + "cfg-if", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "248e3bacc7dc6baa3b21e405ee045c3047101a49145e7e9eca583ab4c2ca5345" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-mac" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bff07008ec701e8028e2ceb8f83f0e4274ee62bd2dbdc4fefff2e9a91824081a" +dependencies = [ + "generic-array", + "subtle", +] + +[[package]] +name = "darling" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "109c1ca6e6b7f82cc233a97004ea8ed7ca123a9af07a8230878fcfda9b158bf0" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 1.0.109", +] + +[[package]] +name = "darling_macro" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e" +dependencies = [ + "darling_core", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "der" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c7ed52955ce76b1554f509074bb357d3fb8ac9b51288a65a3fd480d1dfba946" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "derive_more" +version = "0.99.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fb810d30a7c1953f91334de7244731fc3f3c10d7fe163338a35b9f640960321" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 1.0.109", +] + +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "dotenv" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "educe" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "079044df30bb07de7d846d41a184c4b00e66ebdac93ee459253474f3a47e50ae" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "either" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fcaabb2fef8c910e7f4c7ce9f67a1283a1715879a7c230ca9d6d1ae31f16d91" +dependencies = [ + "serde", +] + +[[package]] +name = "encoding" +version = "0.2.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b0d943856b990d12d3b55b359144ff341533e516d94098b1d3fc1ac666d36ec" +dependencies = [ + "encoding-index-japanese", + "encoding-index-korean", + "encoding-index-simpchinese", + "encoding-index-singlebyte", + "encoding-index-tradchinese", +] + +[[package]] +name = "encoding-index-japanese" +version = "1.20141219.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04e8b2ff42e9a05335dbf8b5c6f7567e5591d0d916ccef4e0b1710d32a0d0c91" +dependencies = [ + "encoding_index_tests", +] + +[[package]] +name = "encoding-index-korean" +version = "1.20141219.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dc33fb8e6bcba213fe2f14275f0963fd16f0a02c878e3095ecfdf5bee529d81" +dependencies = [ + "encoding_index_tests", +] + +[[package]] +name = "encoding-index-simpchinese" +version = "1.20141219.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87a7194909b9118fc707194baa434a4e3b0fb6a5a757c73c3adb07aa25031f7" +dependencies = [ + "encoding_index_tests", +] + +[[package]] +name = "encoding-index-singlebyte" +version = "1.20141219.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3351d5acffb224af9ca265f435b859c7c01537c0849754d3db3fdf2bfe2ae84a" +dependencies = [ + "encoding_index_tests", +] + +[[package]] +name = "encoding-index-tradchinese" +version = "1.20141219.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd0e20d5688ce3cab59eb3ef3a2083a5c77bf496cb798dc6fcdb75f323890c18" +dependencies = [ + "encoding_index_tests", +] + +[[package]] +name = "encoding_index_tests" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a246d82be1c9d791c5dfde9a2bd045fc3cbba3fa2b11ad558f27d01712f00569" + +[[package]] +name = "encoding_rs" +version = "0.8.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071a31f4ee85403370b58aca746f01041ede6f0da2730960ad001edc2b71b394" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "enum-ordinalize" +version = "3.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4f76552f53cefc9a7f64987c3701b99d982f7690606fd67de1d09712fbf52f1" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 2.0.28", +] + +[[package]] +name = "enumflags2" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c041f5090df68b32bcd905365fd51769c8b9d553fe87fde0b683534f10c01bd2" +dependencies = [ + "enumflags2_derive", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e9a1f9f7d83e59740248a6e14ecf93929ade55027844dfcea78beafccc15745" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.28", +] + +[[package]] +name = "env_filter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a009aa4810eb158359dda09d0c87378e4bbb89b5a801f016885a4707ba24f7ea" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c012a26a7f605efc424dd53697843a72be7dc86ad2d01f7814337794a12231d" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "humantime", + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88bffebc5d80432c9b140ee17875ff173a8ab62faad5b257da912bd2f6c1c0a1" + +[[package]] +name = "errno" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bcfec3a70f97c962c307b2d2c56e358cf1d00b558d74262b5f929ee8cc7e73a" +dependencies = [ + "errno-dragonfly", + "libc", + "windows-sys 0.48.0", +] + +[[package]] +name = "errno-dragonfly" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "fallible_collections" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88c69768c0a15262df21899142bc6df9b9b823546d4b4b9a7bc2d6c448ec6fd" +dependencies = [ + "hashbrown 0.13.2", +] + +[[package]] +name = "fastrand" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6999dc1837253364c2ebb0704ba97994bd874e8f195d665c50b7548f6ea92764" + +[[package]] +name = "fdeflate" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d329bdeac514ee06249dabc27877490f17f5d371ec693360768b838e19f3ae10" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "flate2" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b9429470923de8e8cbd4d2dc513535400b4b3fef0319fb5c4e1f520a7bef743" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "flume" +version = "0.10.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1657b4441c3403d9f7b3409e47575237dac27b1b5726df654a6ecbf92f0f7577" +dependencies = [ + "futures-core", + "futures-sink", + "pin-project", + "spin 0.9.8", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[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 = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-channel" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "955518d47e09b25bbebc7a18df10b81f0c766eaf4c4f1cccef2fca5f2a4fb5f2" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bca583b7e26f571124fe5b7561d49cb2868d79116cfa0eefce955557c6fee8c" + +[[package]] +name = "futures-executor" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccecee823288125bd88b4d7f565c9e58e41858e47ab72e8ea2d64e93624386e0" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fff74096e71ed47f8e023204cfd0aa1289cd54ae5430a9523be060cdb849964" + +[[package]] +name = "futures-sink" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f43be4fe21a13b9781a69afa4985b0f6ee0e1afab2c6f454a8cf30e2b2237b6e" + +[[package]] +name = "futures-task" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76d3d132be6c0e6aa1534069c705a74a5997a356c0dc2f86a47765e5617c5b65" + +[[package]] +name = "futures-util" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b01e40b772d54cf6c6d721c1d1abd0647a0106a12ecaa1c186273392a69533" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4136b2a15dd319360be1c07d9933517ccf0be8f16bf62a3bee4f0d618df427" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "gimli" +version = "0.27.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c80984affa11d98d1b88b66ac8853f143217b399d3c74116778ff8fdb4ed2e" + +[[package]] +name = "glob" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" + +[[package]] +name = "h2" +version = "0.3.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97ec8491ebaf99c8eaa73058b045fe58073cd6be7f596ac993ced0b0a0c01049" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http", + "indexmap 1.9.3", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" +dependencies = [ + "ahash 0.8.3", +] + +[[package]] +name = "hashbrown" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c6201b9ff9fd90a5a3bac2e56a830d0caa509576f0e503818ee82c181b3437a" +dependencies = [ + "ahash 0.8.3", + "allocator-api2", +] + +[[package]] +name = "hashlink" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312f66718a2d7789ffef4f4b7b213138ed9f1eb3aa1d0d82fc99f88fb3ffd26f" +dependencies = [ + "hashbrown 0.14.0", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "hermit-abi" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "443144c8cdadd93ebf52ddb4056d257f5b52c04d3c804e657d19eb73fc33668b" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791a029f6b9fc27657f6f188ec6e5e43f6911f6f878e0dc5501396e09809d437" +dependencies = [ + "hmac 0.12.1", +] + +[[package]] +name = "hmac" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1441c6b1e930e2817404b5046f1f989899143a12bf92de603b69f4e0aee1e15" +dependencies = [ + "crypto-mac", + "digest 0.9.0", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "home" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5444c27eef6923071f7ebcc33e3444508466a76f7a2b93da00ed6e19f30c1ddb" +dependencies = [ + "windows-sys 0.48.0", +] + +[[package]] +name = "hound" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d13cdbd5dbb29f9c88095bbdc2590c9cba0d0a1269b983fef6b2cdd7e9f4db1" + +[[package]] +name = "html-escape" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d1ad449764d627e22bfd7cd5e8868264fc9236e07c752972b4080cd351cb476" +dependencies = [ + "utf8-width", +] + +[[package]] +name = "html-minifier" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8f62e1c8ee7bbdda853aebbbfb78b75a549a45ab031665a5da07a1579a1ad8" +dependencies = [ + "cow-utils", + "educe", + "html-escape", + "minifier", +] + +[[package]] +name = "http" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd6effc99afb63425aff9b05836f029929e345a6148a14b7ecd5ab67af944482" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-range" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4a1e36c821dbe04574f602848a19f742f4fb3c98d40449f11bcad18d6b17421" + +[[package]] +name = "humansize" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7" +dependencies = [ + "libm", +] + +[[package]] +name = "humantime" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" + +[[package]] +name = "iana-time-zone" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fad5b825842d2b38bd206f3e81d6957625fd7f0a361e345c30e01a0ae2dd613" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows", +] + +[[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 = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[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 = "image" +version = "0.24.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f3dfdbdd72063086ff443e297b61695500514b1e41095b6fb9a5ab48a70a711" +dependencies = [ + "bytemuck", + "byteorder", + "color_quant", + "num-rational", + "num-traits", + "png", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", +] + +[[package]] +name = "indexmap" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5477fe2230a79769d8dc68e0eabf5437907c0457a5614a9e8dddb67f65eb65d" +dependencies = [ + "equivalent", + "hashbrown 0.14.0", +] + +[[package]] +name = "ipnetwork" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf466541e9d546596ee94f9f69590f89473455f88372423e0008fc1a7daf100e" +dependencies = [ + "serde", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b02a5381cc465bd3041d84623d0fa3b66738b52b8e2fc3bab8ad63ab032f4a" + +[[package]] +name = "jobserver" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "936cfd212a0155903bcbc060e316fb6cc7cbf2e1907329391ebadc1fe0ce77c2" +dependencies = [ + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5f195fe497f702db0f318b07fdd68edb16955aed830df8363d837542f8f935a" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "jsonwebtoken" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "155c4d7e39ad04c172c5e3a99c434ea3b4a7ba7960b38ecd562b270b097cce09" +dependencies = [ + "base64 0.21.2", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + +[[package]] +name = "language-tags" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +dependencies = [ + "spin 0.5.2", +] + +[[package]] +name = "libc" +version = "0.2.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a08173bc88b7955d1b3145aa561539096c421ac8debde8cbc3612ec635fee29b" + +[[package]] +name = "libm" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7012b1bbb0719e1097c47611d3898568c546d597c2e74d66f6087edd5233ff4" + +[[package]] +name = "libsqlite3-sys" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afc22eff61b133b115c6e8c74e818c628d6d5e7a502afea6f64dee076dd94326" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09fc20d2ca12cb9f044c93e3bd6d32d523e6e2ec3db4f7b2939cd99026ecd3f0" + +[[package]] +name = "local-channel" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f303ec0e94c6c54447f84f3b0ef7af769858a9c4ef56ef2a986d3dcd4c3fc9c" +dependencies = [ + "futures-core", + "futures-sink", + "futures-util", + "local-waker", +] + +[[package]] +name = "local-waker" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34f76eb3611940e0e7d53a9aaa4e6a3151f69541a282fd0dad5571420c53ff1" + +[[package]] +name = "lock_api" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1cc9717a20b1bb222f333e6a92fd32f7d8a18ddc5a3191a11af45dcbf4dcd16" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "lodepng" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0ad39f75bbaa4b10bb6f2316543632a8046a5bcf9c785488d79720b21f044f8" +dependencies = [ + "crc32fast", + "fallible_collections", + "flate2", + "libc", + "rgb", +] + +[[package]] +name = "log" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b06a4cde4c0f271a446782e3eff8de789548ce57dbc8eca9292c27f4a42004b4" + +[[package]] +name = "md-5" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5a279bb9607f9f53c22d496eade00d138d1bdcccd07d74650387cf94942a15" +dependencies = [ + "block-buffer 0.9.0", + "digest 0.9.0", + "opaque-debug", +] + +[[package]] +name = "md-5" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6365506850d44bff6e2fbcb5176cf63650e48bd45ef2fe2665ae1570e0f4b9ca" +dependencies = [ + "digest 0.10.7", +] + +[[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 = "minifier" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95bbbf96b9ac3482c2a25450b67a15ed851319bc5fabf3b40742ea9066e84282" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7" +dependencies = [ + "adler", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "927a765cd3fc26206e66b296465fa9d3e5ab003e651c1b3c060e7956d96b19d2" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.48.0", +] + +[[package]] +name = "nekrochan" +version = "0.1.0" +dependencies = [ + "actix", + "actix-files", + "actix-multipart", + "actix-web", + "actix-web-actors", + "anyhow", + "askama", + "captcha", + "chrono", + "chrono-tz", + "dotenv", + "encoding", + "enumflags2", + "env_logger", + "fs_extra", + "glob", + "html-minifier", + "ipnetwork", + "jsonwebtoken", + "lazy_static", + "log", + "num-traits", + "pwhash", + "rand", + "redis", + "regex", + "serde", + "serde_json", + "serde_qs", + "sha256", + "sqlx", + "thiserror", + "tokio", + "toml", + "uuid", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-bigint" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f93ab6289c7b344a8a9f60f88d80aa20032336fe78da341afc91c8a2341fc75f" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" +dependencies = [ + "byteorder", + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-integer" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225d3389fb3509a24c93f5c29eb6bde2586b98d9f016636dff58d7c6f7569cd9" +dependencies = [ + "autocfg", + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d03e6c028c5dc5cac6e2dec0efda81fc887605bb3d884578bb6d6bf7514e252" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0638a1c9d0a3c0914158145bc76cff373a75a627e6ecbfb71cbe6f453a5a19b0" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f30b0abd723be7e2ffca1272140fac1a2f084c77ec3e123c192b66af1ee9e6c2" +dependencies = [ + "autocfg", + "libm", +] + +[[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 = "object" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bda667d9f2b5051b8833f59f3bf748b28ef54f850f4fcb389a252aa383866d1" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" + +[[package]] +name = "opaque-debug" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" + +[[package]] +name = "parking_lot" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93f00c865fe7cabf650081affecd3871070f26767e7b2070a3ffae14c654b447" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets 0.48.1", +] + +[[package]] +name = "parse-size" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "944553dd59c802559559161f9816429058b869003836120e262e8caec061b7ae" + +[[package]] +name = "parse-zoneinfo" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c705f256449c60da65e11ff6626e0c16a0a0b96aaa348de61376b249bc340f41" +dependencies = [ + "regex", +] + +[[package]] +name = "paste" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4b27ab7be369122c218afc2079489cdcb4b517c0a3fc386ff11e1fedfcc2b35" + +[[package]] +name = "pem" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3163d2912b7c3b52d651a055f2c7eec9ba5cd22d26ef75b8dd3a59980b185923" +dependencies = [ + "base64 0.21.2", + "serde", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94" + +[[package]] +name = "phf" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8d39688d359e6b34654d328e262234662d16cc0f60ec8dcbe5e718709342a5a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e4cc64c2ad9ebe670cb8fd69dd50ae301650392e81c05f9bfcb2d5bdbc24b0" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_shared" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90fcb95eef784c2ac79119d1dd819e162b5da872ce6f3c3abe1e8ca1c082f72b" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "030ad2bc4db10a8944cb0d837f158bdfec4d4a4873ab701a95046770d11f8842" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec2e072ecce94ec471b13398d5402c188e76ac03cf74dd1a975161b23a3f6d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.28", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cc1b0bf1727a77a54b6654e7b5f1af8604923edc8b81885f8ec92f9e3f0a05" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26072860ba924cbfa98ea39c8c19b4dd6a4a25423dbdf219c1eca91aa0cf6964" + +[[package]] +name = "png" +version = "0.17.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59871cc5b6cce7eaccca5a802b4173377a1c2ba90654246789a8fa2334426d11" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[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.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18fb31db3f9bddb2ea821cde30a9f70117e3f119938b5ee630b7403aa6e2ead9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pure-rust-locales" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed02a829e62dc2715ceb8afb4f80e298148e1345749ceb369540fe0eb3368432" + +[[package]] +name = "pwhash" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419a3ad8fa9f9d445e69d9b185a24878ae6e6f55c96e4512f4a0e28cd3bc5c56" +dependencies = [ + "blowfish", + "byteorder", + "hmac 0.10.1", + "md-5 0.9.1", + "rand", + "sha-1", + "sha2 0.9.9", +] + +[[package]] +name = "quote" +version = "1.0.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50f3b39ccfb720540debaa0164757101c08ecb8d326b15358ce76a62c7e85965" +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 = "redis" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c580d9cbbe1d1b479e8d67cf9daf6a62c957e6846048408b80b43ac3f6af84cd" +dependencies = [ + "async-trait", + "bytes", + "combine", + "futures-util", + "itoa", + "percent-encoding", + "pin-project-lite", + "ryu", + "serde", + "serde_json", + "sha1_smol", + "socket2 0.4.9", + "tokio", + "tokio-util", + "url", +] + +[[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 = "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 = "rgb" +version = "0.8.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20ec2d3e3fc7a92ced357df9cebd5a10b6fb2aa1ee797bf7e9ce2f17dffc8f59" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "ring" +version = "0.17.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb0205304757e5d899b9c2e448b867ffd03ae7f988002e47cd24954391394d0b" +dependencies = [ + "cc", + "getrandom", + "libc", + "spin 0.9.8", + "untrusted", + "windows-sys 0.48.0", +] + +[[package]] +name = "rsa" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ab43bb47d23c1a631b4b680199a45255dce26fa9ab2fa902581f624ff13e6a8" +dependencies = [ + "byteorder", + "const-oid", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-iter", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" + +[[package]] +name = "rustc_version" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac5ffa1efe7548069688cd7028f32591853cd7b5b756d41bcffd2353e4fc75b4" +dependencies = [ + "bitflags 2.3.3", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.48.0", +] + +[[package]] +name = "ryu" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe232bdf6be8c8de797b22184ee71118d63780ea42ac85b61d1baa6d3b782ae9" + +[[package]] +name = "scopeguard" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" + +[[package]] +name = "semver" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0293b4b29daaf487284529cc2f5675b8e57c61f70167ba415a463651fd6a918" + +[[package]] +name = "serde" +version = "1.0.166" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d01b7404f9d441d3ad40e6a636a7782c377d2abdbe4fa2440e2edcc2f4f10db8" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.166" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd83d6dde2b6b2d466e14d9d1acce8816dedee94f735eac6395808b3483c6d6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.28", +] + +[[package]] +name = "serde_json" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f1e14e89be7aa4c4b78bdbdc9eb5bf8517829a600ae8eaa39a6e1d960b5185c" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_plain" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6018081315db179d0ce57b1fe4b62a12a0028c9cf9bbef868c9cf477b3c34ae" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_qs" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0431a35568651e363364210c91983c1da5eb29404d9f0928b67d4ebcfa7d330c" +dependencies = [ + "percent-encoding", + "serde", + "thiserror", +] + +[[package]] +name = "serde_spanned" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12022b835073e5b11e90a14f86838ceb1c8fb0325b72416845c487ac0fa95e80" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha-1" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99cd6713db3cf16b6c84e06321e049a9b9f699826e16096d23bbcc44d15d51a6" +dependencies = [ + "block-buffer 0.9.0", + "cfg-if", + "cpufeatures", + "digest 0.9.0", + "opaque-debug", +] + +[[package]] +name = "sha1" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f04293dc80c3993519f2d7f6f511707ee7094fe0c6d3406feb330cdb3540eba3" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.10.7", +] + +[[package]] +name = "sha1_smol" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae1a47186c03a32177042e55dbc5fd5aee900b8e0069a8d70fba96a9375cd012" + +[[package]] +name = "sha2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" +dependencies = [ + "block-buffer 0.9.0", + "cfg-if", + "cpufeatures", + "digest 0.9.0", + "opaque-debug", +] + +[[package]] +name = "sha2" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479fb9d862239e610720565ca91403019f2f00410f1864c5aa7479b950a76ed8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.10.7", +] + +[[package]] +name = "sha256" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08a975c1bc0941703000eaf232c4d8ce188d8d5408d6344b6b2c8c6262772828" +dependencies = [ + "hex", + "sha2 0.10.7", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1" +dependencies = [ + "libc", +] + +[[package]] +name = "signature" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e1788eed21689f9cf370582dfc467ef36ed9c707f073528ddafa8d83e3b8500" +dependencies = [ + "digest 0.10.7", + "rand_core", +] + +[[package]] +name = "simd-adler32" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "238abfbb77c1915110ad968465608b68e869e0772622c9656714e73e5a1a522f" + +[[package]] +name = "simple_asn1" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc4e5204eb1910f40f9cfa375f6f05b68c3abac4b6fd879c8ff5e7ae8a0a085" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror", + "time", +] + +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + +[[package]] +name = "slab" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6528351c9bc8ab22353f9d776db39a20288e8d6c37ef8cfe3317cf875eecfc2d" +dependencies = [ + "autocfg", +] + +[[package]] +name = "smallvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb4feee49fdd9f707ef802e22365a35de4b7b299de4763d44bfea899442ff9" + +[[package]] +name = "socket2" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64a4a911eed85daf18834cfaa86a79b7d266ff93ff5ba14005426219480ed662" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "socket2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2538b18701741680e0322a2302176d3253a35388e2e62f172f64f4f16605f877" +dependencies = [ + "libc", + "windows-sys 0.48.0", +] + +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1e996ef02c474957d681f1b05213dfb0abab947b446a62d37770b23500184a" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlformat" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c12bc9199d1db8234678b7051747c07f517cdcf019262d1847b94ec8b1aee3e" +dependencies = [ + "itertools", + "nom", + "unicode_categories", +] + +[[package]] +name = "sqlx" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91ef53c86d2066e04f0ac6b1364f16d13d82388e2d07f11a5c71782345555761" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a22fd81e9c1ad53c562edb869ff042b215d4eadefefc4784bacfbfd19835945" +dependencies = [ + "ahash 0.8.3", + "atoi", + "byteorder", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "dotenvy", + "either", + "event-listener", + "futures-channel", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashlink", + "hex", + "indexmap 2.0.0", + "ipnetwork", + "log", + "memchr", + "once_cell", + "paste", + "percent-encoding", + "serde", + "serde_json", + "sha2 0.10.7", + "smallvec", + "sqlformat", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", +] + +[[package]] +name = "sqlx-macros" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00bb7c096a202b8164c175614cbfb79fe0e1e0a3d50e0374526183ef2974e4a2" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 1.0.109", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37d644623ab9699014e5b3cb61a040d16caa50fd477008f63f1399ae35498a58" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2 0.10.7", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 1.0.109", + "tempfile", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8264c59b28b6858796acfcedc660aa4c9075cc6e4ec8eb03cdca2a3e725726db" +dependencies = [ + "atoi", + "base64 0.21.2", + "bitflags 2.3.3", + "byteorder", + "bytes", + "chrono", + "crc", + "digest 0.10.7", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac 0.12.1", + "itoa", + "log", + "md-5 0.10.5", + "memchr", + "once_cell", + "percent-encoding", + "rand", + "rsa", + "serde", + "sha1", + "sha2 0.10.7", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cab6147b81ca9213a7578f1b4c9d24c449a53953cd2222a7b5d7cd29a5c3139" +dependencies = [ + "atoi", + "base64 0.21.2", + "bitflags 2.3.3", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "hex", + "hkdf", + "hmac 0.12.1", + "home", + "ipnetwork", + "itoa", + "log", + "md-5 0.10.5", + "memchr", + "once_cell", + "rand", + "serde", + "serde_json", + "sha1", + "sha2 0.10.7", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59fba60afa64718104b71eec6984f8779d4caffff3b30cde91a75843c7efc126" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "sqlx-core", + "tracing", + "url", +] + +[[package]] +name = "stringprep" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee348cb74b87454fff4b551cbf727025810a004f88aeacae7f85b87f4e9a1c1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + +[[package]] +name = "subtle" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04361975b3f5e348b2189d8dc55bc942f278b2d482a6a0365de5bdd62d351567" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc02fddf48964c42031a0b3fe0428320ecf3a73c401040fc0096f97794310651" +dependencies = [ + "cfg-if", + "fastrand", + "redox_syscall", + "rustix", + "windows-sys 0.48.0", +] + +[[package]] +name = "thiserror" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "611040a08a0439f8248d1990b111c95baa9c704c805fa1f62104b39655fd7f90" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "090198534930841fab3a5d1bb637cde49e339654e606195f8d9c76eeb081dc96" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.28", +] + +[[package]] +name = "time" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea9e1b3cf1243ae005d9e74085d4d542f3125458f3a81af210d901dcd7411efd" +dependencies = [ + "itoa", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7300fbefb4dadc1af235a9cef3737cea692a9d97e1b9cbcd4ebdae6f8868e6fb" + +[[package]] +name = "time-macros" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "372950940a5f07bf38dbe211d7283c9e6d7327df53794992d293e534c733d09b" +dependencies = [ + "time-core", +] + +[[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 = "tokio" +version = "1.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d3ce25f50619af8b0aec2eb23deebe84249e19e2ddd393a6e16e3300a6dadfd" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio", + "num_cpus", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.5.3", + "tokio-macros", + "windows-sys 0.48.0", +] + +[[package]] +name = "tokio-macros" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "630bdcf245f78637c13ec01ffae6187cca34625e8c63150d424b59e55af2675e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.28", +] + +[[package]] +name = "tokio-stream" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "397c988d37662c7dda6d2208364a706264bf3d6138b11d436cbac0ad38832842" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "806fe8c2c87eccc8b3267cbae29ed3ab2d0bd37fca70ab622e46aaa9375ddb7d" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", + "tracing", +] + +[[package]] +name = "toml" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ff9e3abce27ee2c9a37f9ad37238c1bdd4e789c84ba37df76aa4d528f5072cc" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3550f4e9685620ac18a50ed434eb3aec30db8ba93b0287467bca5826ea25baf1" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.20.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70f427fce4d84c72b5b732388bf4a9f4531b53f74e2887e3ecb2481f68f66d81" +dependencies = [ + "indexmap 2.0.0", + "serde", + "serde_spanned", + "toml_datetime", + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ce8c33a8d48bd45d624a6e523445fd21ec13d3653cd51f681abf67418f54eb8" +dependencies = [ + "cfg-if", + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f4f31f56159e98206da9efd823404b79b6ef3143b4a7ab76e67b1751b25a4ab" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.28", +] + +[[package]] +name = "tracing-core" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0955b8137a1df6f1a2e9a37d8a6656291ff0297c1a97c24e0d8425fe2312f79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "497961ef93d974e23eb6f433eb5fe1b7930b659f06d12dec6fc44a8f554c0bba" + +[[package]] +name = "unicase" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50f37be617794602aabbeee0be4f259dc1778fabe05e2d67ee8f79326d5cb4f6" +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.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22049a19f4a68748a168c0fc439f9516686aa045927ff767eca0a85101fb6e73" + +[[package]] +name = "unicode-normalization" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36" + +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50bff7831e19200a85b17131d085c25d7811bc4e186efdaf54bbd132994a88cb" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + +[[package]] +name = "utf8-width" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5190c9442dcdaf0ddd50f37420417d219ae5261bbf5db120d0f9bab996c9cba1" + +[[package]] +name = "utf8parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" + +[[package]] +name = "uuid" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f00cc9702ca12d3c81455259621e676d0f7251cec66a21e98fe2e9a37db93b2a" +dependencies = [ + "getrandom", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.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.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7706a72ab36d8cb1f80ffbf0e071533974a60d0a308d01a5d0375bf60499a342" +dependencies = [ + "cfg-if", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ef2b6d3c510e9625e5fe6f509ab07d66a760f0885d858736483c32ed7809abd" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.28", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dee495e55982a3bd48105a7b947fd2a9b4a8ae3010041b9e0faab3f9cd028f1d" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54681b18a46765f095758388f2d0cf16eb8d4169b639ab575a8f5693af210c7b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.28", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca6ad05a4870b2bf5fe995117d3728437bd27d7cd5f06f13c17443ef369775a1" + +[[package]] +name = "whoami" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22fc3756b8a9133049b26c7f61ab35416c130e8c09b660f5b3958b446f52cc50" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f" +dependencies = [ + "windows-targets 0.48.1", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.1", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.0", +] + +[[package]] +name = "windows-targets" +version = "0.48.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05d4b17490f70499f20b9e791dcf6a299785ce8af4d709018206dc5b4953e95f" +dependencies = [ + "windows_aarch64_gnullvm 0.48.0", + "windows_aarch64_msvc 0.48.0", + "windows_i686_gnu 0.48.0", + "windows_i686_msvc 0.48.0", + "windows_x86_64_gnu 0.48.0", + "windows_x86_64_gnullvm 0.48.0", + "windows_x86_64_msvc 0.48.0", +] + +[[package]] +name = "windows-targets" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a18201040b24831fbb9e4eb208f8892e1f50a37feb53cc7ff887feb8f50e7cd" +dependencies = [ + "windows_aarch64_gnullvm 0.52.0", + "windows_aarch64_msvc 0.52.0", + "windows_i686_gnu 0.52.0", + "windows_i686_msvc 0.52.0", + "windows_x86_64_gnu 0.52.0", + "windows_x86_64_gnullvm 0.52.0", + "windows_x86_64_msvc 0.52.0", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91ae572e1b79dba883e0d315474df7305d12f569b400fcf90581b06062f7e1bc" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7764e35d4db8a7921e09562a0304bf2f93e0a51bfccee0bd0bb0b666b015ea" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2ef27e0d7bdfcfc7b868b317c1d32c641a6fe4629c171b8928c7b08d98d7cf3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbaa0368d4f1d2aaefc55b6fcfee13f41544ddf36801e793edbbfd7d7df075ef" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622a1962a7db830d6fd0a69683c80a18fda201879f0f447f065a3b7467daa241" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28637cb1fa3560a16915793afb20081aba2c92ee8af57b4d5f28e4b3e7df313" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4542c6e364ce21bf45d69fdd2a8e455fa38d316158cfd43b3ac1c5b1b19f8e00" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffe5e8e31046ce6230cc7215707b816e339ff4d4d67c65dffa206fd0f7aa7b9a" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2b8a661f7628cbd23440e50b05d705db3686f894fc9580820623656af974b1" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d6fa32db2bc4a2f5abeacf2b69f7992cd09dca97498da74a151a3132c26befd" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7896dbc1f41e08872e9d5e8f8baa8fdd2677f29468c4e156210174edc7f7b953" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a657e1e9d3f514745a572a6846d3c7aa7dbe1658c056ed9c3344c4109a6949e" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04" + +[[package]] +name = "winnow" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "176b6138793677221d420fd2f0aeeced263f197688b36484660da767bca2fa32" +dependencies = [ + "memchr", +] + +[[package]] +name = "zeroize" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0956f1ba7c7909bfb66c2e9e4124ab6f6482560f6628b5aaeba39207c9aad9" + +[[package]] +name = "zstd" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a27595e173641171fc74a1232b7b1c7a7cb6e18222c11e9dfb9888fa424c53c" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "6.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee98ffd0b48ee95e6c5168188e44a54550b1564d9d530ee21d5f0eaed1069581" +dependencies = [ + "libc", + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.8+zstd.1.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5556e6ee25d32df2586c098bbfa278803692a20d0ab9565e049480d52707ec8c" +dependencies = [ + "cc", + "libc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100755 index 0000000..c9aa539 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,54 @@ +[package] +name = "nekrochan" +version = "0.1.0" +edition = "2021" + +[dependencies] +actix = "0.13.3" +actix-files = "0.6.2" +actix-multipart = "0.6.0" +actix-web = { version = "4.3.1", features = ["cookies"] } +actix-web-actors = "4.3.0" +askama = "0.12.0" +anyhow = "1.0.71" +captcha = "0.0.9" +chrono = { version = "0.4.31", features = ["serde", "unstable-locales"] } +chrono-tz = "0.8.5" +dotenv = "0.15.0" +enumflags2 = "0.7.7" +encoding = "0.2.33" +env_logger = "0.11.2" +glob = "0.3.1" +ipnetwork = "0.20.0" +jsonwebtoken = "9.1.0" +lazy_static = "1.4.0" +log = "0.4.19" +num-traits = "0.2.16" +pwhash = "1.0.0" +rand = "0.8.5" +redis = { version = "0.24.0", features = ["aio", "json", "tokio-comp"] } +regex = "1.10.2" +serde = "1.0.166" +serde_json = "1.0.100" +serde_qs = "0.12.0" +sha256 = "1.1.4" +sqlx = { version = "0.7.0", features = [ + "runtime-tokio", + "postgres", + "json", + "chrono", + "ipnetwork", +] } +thiserror = "1.0.41" +tokio = { version = "1.29.1", features = ["rt-multi-thread", "macros"] } +toml = "0.8.6" +uuid = { version = "1.7.0", features = ["v4"] } + +[build-dependencies] +anyhow = "1.0.74" +fs_extra = "1.3.0" +glob = "0.3.1" +html-minifier = "5.0.0" + +[profile.dev] +opt-level = 1 diff --git a/Nekrochan.toml.template b/Nekrochan.toml.template new file mode 100755 index 0000000..06f3fba --- /dev/null +++ b/Nekrochan.toml.template @@ -0,0 +1,47 @@ +[server] +port = ${PORT} +database_url = "postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}" +cache_url = "redis://${REDIS_HOST}/${REDIS_DB}" + +[site] +name = "${SITE_NAME}" +description = "${SITE_DESCRIPTION}" +theme = "yotsuba.css" +links = [] +noko = true + +[secrets] +auth_token = "${AUTH_SECRET}" +secure_trip = "${TRIP_SECRET}" +user_id = "${UID_SECRET}" + +[files] +videos = true +thumb_size = 150 +max_size_mb = 50 +max_height = 10000 +max_width = 10000 +cleanup_interval = 3600 + +[board_defaults] +anon_name = "Anonym" +page_size = 10 +page_count = 20 +file_limit = 1 +bump_limit = 500 +reply_limit = 1000 +locked = false +user_ids = false +flags = false +thread_captcha = "off" +reply_captcha = "off" +board_theme = "yotsuba.css" +require_thread_content = true +require_thread_file = true +require_reply_content = false +require_reply_file = false +antispam = true +antispam_ip = 5 +antispam_content = 10 +antispam_both = 60 +thread_cooldown = 60 diff --git a/README.md b/README.md new file mode 100644 index 0000000..6431abe --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# czchan + +100% český imidžbórdový skript + +> 100% český přestože je kód anglicky... diff --git a/askama.toml b/askama.toml new file mode 100644 index 0000000..7c682a8 --- /dev/null +++ b/askama.toml @@ -0,0 +1,2 @@ +[general] +dirs = ["templates_min"] diff --git a/build.rs b/build.rs new file mode 100755 index 0000000..0dcc6d4 --- /dev/null +++ b/build.rs @@ -0,0 +1,39 @@ +use anyhow::Error; +use fs_extra::dir::{copy, remove, CopyOptions}; +use glob::glob; +use html_minifier::minify; +use std::{ + fs::{read_to_string, File}, + io::Write, +}; + +fn main() -> Result<(), Error> { + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-changed=migrations"); + println!("cargo:rerun-if-changed=templates"); + + remove("templates_min")?; + + copy( + "templates", + "templates_min", + &CopyOptions::new().copy_inside(true), + )?; + + let templates = glob("templates_min/**/*.html")?; + + for path in templates { + let path = path?; + + if !path.is_file() { + continue; + } + + let html = read_to_string(&path)?; + let minified = minify(html)?.replace('\n', "").replace(" ", " "); + + File::create(path)?.write_all(minified.as_bytes())?; + } + + Ok(()) +} diff --git a/configure.sh b/configure.sh new file mode 100755 index 0000000..876170d --- /dev/null +++ b/configure.sh @@ -0,0 +1,68 @@ +set -e + +echo "# Výběr portu" +read -p "Port serveru [7000]: " port + +echo "# Konfigurace databáze" +read -p "Host databáze [localhost]: " db_host +read -p "Port databáze [5432]: " db_port + +read -p "Uživatelské jméno: " db_user +if [ "$db_user" == "" ] +then + echo "Uživatelské jméno je povinné" + exit 1 +fi + +read -p "Heslo: " db_password +if [ "$db_user" == "" ] +then + echo "Heslo je povinné" + exit 1 +fi + +read -p "Jméno databáze: " db_name +if [ "$db_name" == "" ] +then + echo "Jméno databáze je povinné" + exit 1 +fi + +echo "# Konfigurace redisu" +read -p "Host redisu [localhost]: " redis_host +read -p "Číslo databáze [0]: " redis_db + +echo "# Konfigurace stránky" + +read -p "Jméno stránky: " site_name +if [ "$site_name" == "" ] +then + echo "Jméno stránky je povinné" + exit 1 +fi + +read -p "Popis stránky: " site_description +if [ "$site_description" == "" ] +then + echo "Popis stránky je povinný" + exit 1 +fi + +export PORT=${port:-7000} +export DB_HOST=${db_host:-localhost} +export DB_PORT=${db_host:-5432} +export DB_USER=${db_user} +export DB_PASSWORD=${db_password} +export DB_NAME=${db_name} +export REDIS_HOST=${redis_host:-localhost} +export REDIS_DB=${redis_db:-0} +export REDIS_HOST=${redis_host:-localhost} +export SITE_NAME=${site_name} +export SITE_DESCRIPTION=${site_description} + +export AUTH_SECRET=`tr -dc A-Za-z0-9 Nekrochan.toml +mkdir -p ./uploads/thumb diff --git a/migrations/20230710121446_create_tables.sql b/migrations/20230710121446_create_tables.sql new file mode 100755 index 0000000..66e2632 --- /dev/null +++ b/migrations/20230710121446_create_tables.sql @@ -0,0 +1,28 @@ +CREATE TABLE accounts ( + username VARCHAR(32) NOT NULL PRIMARY KEY, + password VARCHAR(64) NOT NULL, + owner BOOLEAN NOT NULL DEFAULT false, + permissions JSONB NOT NULL DEFAULT '0'::jsonb, + created TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE boards ( + id VARCHAR(16) NOT NULL PRIMARY KEY, + name VARCHAR(32) NOT NULL, + description VARCHAR(128) NOT NULL, + banners JSONB NOT NULL, + config JSONB NOT NULL, + created TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE bans ( + id SERIAL NOT NULL PRIMARY KEY, + ip_range INET NOT NULL, + reason TEXT NOT NULL, + board VARCHAR(16) DEFAULT NULL REFERENCES boards(id), + issued_by VARCHAR(32) NOT NULL REFERENCES accounts(username), + appealable BOOLEAN NOT NULL DEFAULT true, + appeal TEXT DEFAULT NULL, + expires TIMESTAMPTZ DEFAULT NULL, + created TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/migrations/20231216092451_global_banners.sql b/migrations/20231216092451_global_banners.sql new file mode 100644 index 0000000..14888f3 --- /dev/null +++ b/migrations/20231216092451_global_banners.sql @@ -0,0 +1,6 @@ +ALTER TABLE boards DROP COLUMN banners; + +CREATE TABLE banners ( + id SERIAL NOT NULL PRIMARY KEY, + banner JSONB NOT NULL +); diff --git a/migrations/20231217111814_create_news.sql b/migrations/20231217111814_create_news.sql new file mode 100644 index 0000000..18cb32d --- /dev/null +++ b/migrations/20231217111814_create_news.sql @@ -0,0 +1,8 @@ +CREATE TABLE news ( + id SERIAL NOT NULL PRIMARY KEY, + title VARCHAR(256) NOT NULL, + content TEXT NOT NULL, + content_nomarkup TEXT NOT NULL, + author VARCHAR(32) NOT NULL REFERENCES accounts(username), + created TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/migrations/20231229180942_remove_references.sql b/migrations/20231229180942_remove_references.sql new file mode 100644 index 0000000..5f125db --- /dev/null +++ b/migrations/20231229180942_remove_references.sql @@ -0,0 +1,2 @@ +ALTER TABLE bans DROP CONSTRAINT bans_issued_by_fkey; +ALTER TABLE news DROP CONSTRAINT news_author_fkey; diff --git a/src/auth.rs b/src/auth.rs new file mode 100755 index 0000000..5ced9fa --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,39 @@ +use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; + +use crate::{ctx::Ctx, error::NekrochanError}; + +#[derive(Serialize, Deserialize)] +pub struct Claims { + pub sub: String, +} + +impl Claims { + pub fn new(sub: String) -> Self { + Self { sub } + } + + pub fn encode(&self, ctx: &Ctx) -> Result { + let header = Header::default(); + let key = EncodingKey::from_secret(ctx.cfg.secrets.auth_token.as_bytes()); + + let auth = encode(&header, &self, &key)?; + + Ok(auth) + } + + pub fn decode(ctx: &Ctx, auth: &str) -> Result { + let key = DecodingKey::from_secret(ctx.cfg.secrets.auth_token.as_bytes()); + + let mut validation = Validation::default(); + validation.required_spec_claims = HashSet::from_iter(["sub".to_owned()]); + validation.validate_exp = false; + + let claims = decode(auth, &key, &validation) + .map_err(|_| NekrochanError::InvalidAuthError)? + .claims; + + Ok(claims) + } +} diff --git a/src/cfg.rs b/src/cfg.rs new file mode 100755 index 0000000..f049f72 --- /dev/null +++ b/src/cfg.rs @@ -0,0 +1,79 @@ +use anyhow::Error; +use serde::{Deserialize, Serialize}; +use tokio::fs::read_to_string; + +#[derive(Deserialize, Debug, Clone)] +pub struct Cfg { + pub server: ServerCfg, + pub site: SiteCfg, + pub secrets: SecretsCfg, + pub files: FilesCfg, + pub board_defaults: BoardCfg, +} + +impl Cfg { + pub async fn load(path: &str) -> Result { + let cfg_string = read_to_string(path).await?; + let cfg: Cfg = toml::from_str(&cfg_string)?; + + Ok(cfg) + } +} + +#[derive(Deserialize, Debug, Clone)] +pub struct ServerCfg { + pub port: u16, + pub database_url: String, + pub cache_url: String, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct SiteCfg { + pub name: String, + pub description: String, + pub theme: String, + pub links: Vec>, + pub noko: bool, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct SecretsCfg { + pub auth_token: String, + pub secure_trip: String, + pub user_id: String, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct FilesCfg { + pub videos: bool, + pub thumb_size: u32, + pub max_size_mb: usize, + pub max_height: u32, + pub max_width: u32, + pub cleanup_interval: u64, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct BoardCfg { + pub anon_name: String, + pub page_size: i64, + pub page_count: i64, + pub file_limit: usize, + pub bump_limit: i32, + pub reply_limit: i32, + pub locked: bool, + pub user_ids: bool, + pub flags: bool, + pub thread_captcha: String, + pub reply_captcha: String, + pub board_theme: String, + pub require_thread_content: bool, + pub require_thread_file: bool, + pub require_reply_content: bool, + pub require_reply_file: bool, + pub antispam: bool, + pub antispam_ip: i64, + pub antispam_content: i64, + pub antispam_both: i64, + pub thread_cooldown: i64, +} diff --git a/src/ctx.rs b/src/ctx.rs new file mode 100755 index 0000000..7fc518e --- /dev/null +++ b/src/ctx.rs @@ -0,0 +1,48 @@ +use actix::{Actor, Addr}; +use anyhow::Error; +use redis::{aio::MultiplexedConnection, Client}; +use sqlx::PgPool; +use std::net::SocketAddr; + +use crate::{cfg::Cfg, live_hub::LiveHub}; + +#[derive(Clone)] +pub struct Ctx { + pub cfg: Cfg, + db: PgPool, + cache: MultiplexedConnection, + hub: Addr, +} + +impl Ctx { + pub async fn new(cfg: Cfg) -> Result { + let db = PgPool::connect(&cfg.server.database_url).await?; + let client = Client::open(cfg.server.cache_url.as_str())?; + let cache = client.get_multiplexed_async_connection().await?; + let sync_cache = client.get_connection()?; + let hub = LiveHub::new(sync_cache).start(); + + Ok(Self { + cfg, + db, + cache, + hub, + }) + } + + pub fn bind_addr(&self) -> SocketAddr { + SocketAddr::from(([127, 0, 0, 1], self.cfg.server.port)) + } + + pub fn db(&self) -> &PgPool { + &self.db + } + + pub fn cache(&self) -> MultiplexedConnection { + self.cache.clone() + } + + pub fn hub(&self) -> Addr { + self.hub.clone() + } +} diff --git a/src/db/account.rs b/src/db/account.rs new file mode 100755 index 0000000..4b000e8 --- /dev/null +++ b/src/db/account.rs @@ -0,0 +1,117 @@ +use redis::{AsyncCommands, JsonAsyncCommands}; +use sqlx::{query, query_as, types::Json}; + +use super::models::Account; +use crate::{ctx::Ctx, error::NekrochanError, perms::PermissionWrapper}; + +impl Account { + pub async fn create( + ctx: &Ctx, + username: String, + password: String, + ) -> Result { + let account = + query_as("INSERT INTO accounts (username, password) VALUES ($1, $2) RETURNING *") + .bind(&username) + .bind(password) + .fetch_one(ctx.db()) + .await?; + + ctx.cache() + .json_set(format!("accounts:{username}"), ".", &account) + .await?; + + Ok(account) + } + + pub async fn read(ctx: &Ctx, username: String) -> Result, NekrochanError> { + let account: Option = ctx + .cache() + .json_get(format!("accounts:{username}"), ".") + .await?; + + let account = match account { + Some(json) => Some(serde_json::from_str(&json)?), + None => None, + }; + + Ok(account) + } + + pub async fn read_all(ctx: &Ctx) -> Result, NekrochanError> { + let accounts = query_as("SELECT * FROM accounts ORDER BY owner DESC, created DESC") + .fetch_all(ctx.db()) + .await?; + + Ok(accounts) + } + + pub async fn update_password(&self, ctx: &Ctx, password: String) -> Result<(), NekrochanError> { + query("UPDATE accounts SET password = $1 WHERE username = $2") + .bind(&password) + .bind(&self.username) + .execute(ctx.db()) + .await?; + + ctx.cache() + .json_set(format!("accounts:{}", self.username), "password", &password) + .await?; + + Ok(()) + } + + pub async fn update_permissions( + &self, + ctx: &Ctx, + permissions: u64, + ) -> Result<(), NekrochanError> { + query("UPDATE accounts SET permissions = $1 WHERE username = $2") + .bind(Json(permissions)) + .bind(&self.username) + .execute(ctx.db()) + .await?; + + ctx.cache() + .json_set( + format!("accounts:{}", self.username), + "permissions", + &permissions, + ) + .await?; + + Ok(()) + } + + pub async fn update_owner(&self, ctx: &Ctx, owner: bool) -> Result<(), NekrochanError> { + query("UPDATE accounts SET owner = $1 WHERE username = $2") + .bind(owner) + .bind(&self.username) + .execute(ctx.db()) + .await?; + + ctx.cache() + .json_set(format!("accounts:{}", self.username), "owner", &owner) + .await?; + + Ok(()) + } + + pub async fn delete(&self, ctx: &Ctx) -> Result<(), NekrochanError> { + query("DELETE FROM accounts WHERE username = $1") + .bind(&self.username) + .execute(ctx.db()) + .await?; + + ctx.cache() + .del(format!("accounts:{}", self.username)) + .await?; + + Ok(()) + } +} + +impl Account { + pub fn perms(&self) -> PermissionWrapper { + PermissionWrapper::new(self.permissions.0, self.owner) + } +} diff --git a/src/db/ban.rs b/src/db/ban.rs new file mode 100755 index 0000000..3ae2a04 --- /dev/null +++ b/src/db/ban.rs @@ -0,0 +1,107 @@ +use chrono::{DateTime, Utc}; +use ipnetwork::IpNetwork; +use sqlx::{query, query_as}; +use std::{collections::HashMap, net::IpAddr}; + +use super::models::Ban; +use crate::{ctx::Ctx, error::NekrochanError}; + +impl Ban { + pub async fn create( + ctx: &Ctx, + account: String, + board: Option, + ip_range: IpNetwork, + reason: String, + appealable: bool, + expires: Option>, + ) -> Result { + let ban = query_as("INSERT INTO bans (ip_range, reason, board, issued_by, appealable, expires) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *") + .bind(ip_range) + .bind(reason) + .bind(board) + .bind(account) + .bind(appealable) + .bind(expires) + .fetch_one(ctx.db()) + .await?; + + Ok(ban) + } + + pub async fn read(ctx: &Ctx, board: String, ip: IpAddr) -> Result, NekrochanError> { + let ban = query_as("SELECT * FROM bans WHERE (expires > CURRENT_TIMESTAMP OR expires IS NULL) AND (board = $1 OR board IS NULL) AND (ip_range >> $2 OR ip_range = $2)") + .bind(board) + .bind(ip) + .fetch_optional(ctx.db()) + .await?; + + Ok(ban) + } + + pub async fn read_global(ctx: &Ctx, ip: IpNetwork) -> Result, NekrochanError> { + let ban = query_as("SELECT * FROM bans WHERE (expires > CURRENT_TIMESTAMP OR expires IS NULL) AND board IS NULL AND (ip_range >> $1 OR ip_range = $1)") + .bind(ip) + .fetch_optional(ctx.db()) + .await?; + + Ok(ban) + } + + pub async fn read_all(ctx: &Ctx) -> Result, NekrochanError> { + let bans = + query_as("SELECT * FROM bans WHERE (expires > CURRENT_TIMESTAMP OR expires IS NULL) ORDER BY created DESC") + .fetch_all(ctx.db()) + .await?; + + Ok(bans) + } + + pub async fn read_by_id(ctx: &Ctx, id: i32) -> Result, NekrochanError> { + let ban = query_as("SELECT * FROM bans WHERE id = $1") + .bind(id) + .fetch_optional(ctx.db()) + .await?; + + Ok(ban) + } + + pub async fn read_by_ip( + ctx: &Ctx, + ip: IpAddr, + ) -> Result, Ban>, NekrochanError> { + let bans: Vec = query_as("SELECT * FROM bans WHERE (expires > CURRENT_TIMESTAMP OR expires IS NULL) AND (ip_range >> $1 OR ip_range = $1)") + .bind(ip) + .fetch_all(ctx.db()) + .await?; + + let mut ban_map = HashMap::new(); + + for ban in bans { + let board = ban.board.clone(); + + ban_map.insert(board, ban); + } + + Ok(ban_map) + } + + pub async fn update_appeal(&self, ctx: &Ctx, appeal: String) -> Result<(), NekrochanError> { + query("UPDATE bans SET appeal = $1 WHERE id = $2") + .bind(appeal) + .bind(self.id) + .execute(ctx.db()) + .await?; + + Ok(()) + } + + pub async fn delete(&self, ctx: &Ctx) -> Result<(), NekrochanError> { + query("DELETE FROM bans WHERE id = $1") + .bind(self.id) + .execute(ctx.db()) + .await?; + + Ok(()) + } +} diff --git a/src/db/banner.rs b/src/db/banner.rs new file mode 100644 index 0000000..ab2b68f --- /dev/null +++ b/src/db/banner.rs @@ -0,0 +1,64 @@ +use redis::AsyncCommands; +use sqlx::{query, query_as, types::Json}; + +use super::models::{Banner, File}; +use crate::{ctx::Ctx, NekrochanError}; + +impl Banner { + pub async fn create(ctx: &Ctx, banner: File) -> Result { + let banner: Self = query_as("INSERT INTO banners (banner) VALUES ($1) RETURNING *") + .bind(Json(banner)) + .fetch_one(ctx.db()) + .await?; + + ctx.cache() + .zadd("banners", serde_json::to_string(&banner)?, banner.id) + .await?; + + Ok(banner) + } + + pub async fn read(ctx: &Ctx, id: i32) -> Result, NekrochanError> { + let banners: Vec = ctx.cache().zrangebyscore("banners", id, id).await?; + let json = banners.get(0); + + let banner = match json { + Some(json) => Some(serde_json::from_str(json)?), + None => None, + }; + + Ok(banner) + } + + pub async fn read_all(ctx: &Ctx) -> Result, NekrochanError> { + let banners_str: Vec = ctx.cache().zrange("banners", 0, -1).await?; + let banners_json = format!("[{}]", banners_str.join(",")); // If it works, it works + let banners = serde_json::from_str(&banners_json)?; + + Ok(banners) + } + + pub async fn read_random(ctx: &Ctx) -> Result, NekrochanError> { + let banner: Option = ctx.cache().zrandmember("banners", None).await?; + + let banner = match banner { + Some(json) => Some(serde_json::from_str(&json)?), + None => None, + }; + + Ok(banner) + } + + pub async fn remove(&self, ctx: &Ctx) -> Result<(), NekrochanError> { + self.banner.delete().await; + + query("DELETE FROM banners WHERE id = $1") + .bind(self.id) + .execute(ctx.db()) + .await?; + + ctx.cache().zrembyscore("banners", self.id, self.id).await?; + + Ok(()) + } +} diff --git a/src/db/board.rs b/src/db/board.rs new file mode 100755 index 0000000..f701333 --- /dev/null +++ b/src/db/board.rs @@ -0,0 +1,249 @@ +use redis::{cmd, AsyncCommands, Connection, JsonAsyncCommands, JsonCommands}; +use sqlx::{query, query_as, types::Json}; +use std::collections::HashMap; + +use super::models::{Board, File}; +use crate::{cfg::BoardCfg, ctx::Ctx, error::NekrochanError}; + +impl Board { + pub async fn create( + ctx: &Ctx, + id: String, + name: String, + description: String, + ) -> Result { + let config = Json(ctx.cfg.board_defaults.clone()); + + let board: Board = query_as("INSERT INTO boards (id, name, description, config) VALUES ($1, $2, $3, $4) RETURNING *") + .bind(id) + .bind(name) + .bind(description) + .bind(config) + .fetch_one(ctx.db()) + .await?; + + query(&format!( + r#"CREATE TABLE posts_{} ( + id BIGSERIAL NOT NULL PRIMARY KEY, + board VARCHAR(16) NOT NULL DEFAULT '{}' REFERENCES boards(id), + thread BIGINT DEFAULT NULL REFERENCES posts_{}(id), + name VARCHAR(32) NOT NULL, + user_id VARCHAR(6) NOT NULL DEFAULT '000000', + tripcode VARCHAR(12) DEFAULT NULL, + capcode VARCHAR(32) DEFAULT NULL, + email VARCHAR(256) DEFAULT NULL, + content TEXT NOT NULL, + content_nomarkup TEXT NOT NULL, + files JSONB NOT NULL, + password VARCHAR(64) DEFAULT NULL, + country VARCHAR(2) NOT NULL, + ip INET NOT NULL, + bumps INT NOT NULL DEFAULT 0, + replies INT NOT NULL DEFAULT 0, + quotes BIGINT[] NOT NULL DEFAULT '{{}}', + sticky BOOLEAN NOT NULL DEFAULT false, + locked BOOLEAN NOT NULL DEFAULT false, + reported TIMESTAMPTZ DEFAULT NULL, + reports JSONB NOT NULL DEFAULT '[]'::jsonb, + bumped TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + created TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP + )"#, + board.id, board.id, board.id + )) + .execute(ctx.db()) + .await?; + + ctx.cache() + .set(format!("board_threads:{}", board.id), 0) + .await?; + + ctx.cache().lpush("board_ids", &board.id).await?; + + ctx.cache() + .json_set(format!("boards:{}", board.id), ".", &board) + .await?; + + cmd("SORT") + .arg("board_ids") + .arg("ALPHA") + .arg("STORE") + .arg("board_ids") + .query_async(&mut ctx.cache()) + .await?; + + update_overboard(ctx, Self::read_all(ctx).await?).await?; + + Ok(board) + } + + pub async fn read(ctx: &Ctx, id: String) -> Result, NekrochanError> { + let board: Option = ctx.cache().json_get(format!("boards:{id}"), ".").await?; + + let board = match board { + Some(json) => Some(serde_json::from_str(&json)?), + None => None, + }; + + Ok(board) + } + + pub fn read_sync(cache: &mut Connection, id: String) -> Result, NekrochanError> { + let board: Option = cache.json_get(format!("boards:{id}"), ".")?; + + let board = match board { + Some(json) => Some(serde_json::from_str(&json)?), + None => None, + }; + + Ok(board) + } + + pub async fn read_all(ctx: &Ctx) -> Result, NekrochanError> { + let mut boards = Vec::new(); + let ids: Vec = ctx.cache().lrange("board_ids", 0, -1).await?; + + for id in ids { + if let Some(board) = Self::read(ctx, id).await? { + boards.push(board); + } + } + + Ok(boards) + } + + pub async fn read_all_map(ctx: &Ctx) -> Result, NekrochanError> { + let mut boards = HashMap::new(); + let ids: Vec = ctx.cache().lrange("board_ids", 0, -1).await?; + + for id in ids { + if let Some(board) = Self::read(ctx, id.clone()).await? { + boards.insert(id, board); + } + } + + Ok(boards) + } + + pub async fn read_post_count(&self, ctx: &Ctx) -> Result { + let (count,) = query_as(&format!("SELECT last_value FROM posts_{}_id_seq", self.id)) + .fetch_one(ctx.db()) + .await?; + + Ok(count) + } + + pub async fn update_name(&self, ctx: &Ctx, name: String) -> Result<(), NekrochanError> { + query("UPDATE boards SET name = $1 WHERE id = $2") + .bind(&name) + .bind(&self.id) + .execute(ctx.db()) + .await?; + + ctx.cache() + .json_set(format!("boards:{}", self.id), "name", &name) + .await?; + + Ok(()) + } + + pub async fn update_description( + &self, + ctx: &Ctx, + description: String, + ) -> Result<(), NekrochanError> { + query("UPDATE boards SET description = $1 WHERE id = $2") + .bind(&description) + .bind(&self.id) + .execute(ctx.db()) + .await?; + + ctx.cache() + .json_set(format!("boards:{}", self.id), "description", &description) + .await?; + + Ok(()) + } + + pub async fn update_banners( + &self, + ctx: &Ctx, + banners: Vec, + ) -> Result<(), NekrochanError> { + query("UPDATE boards SET banners = $1 WHERE id = $2") + .bind(Json(&banners)) + .bind(&self.id) + .execute(ctx.db()) + .await?; + + ctx.cache() + .json_set(format!("boards:{}", self.id), "banners", &banners) + .await?; + + Ok(()) + } + + pub async fn update_config(&self, ctx: &Ctx, config: BoardCfg) -> Result<(), NekrochanError> { + query("UPDATE boards SET config = $1 WHERE id = $2") + .bind(Json(&config)) + .bind(&self.id) + .execute(ctx.db()) + .await?; + + ctx.cache() + .json_set(format!("boards:{}", self.id), "config", &config) + .await?; + + Ok(()) + } + + pub async fn delete(&self, ctx: &Ctx) -> Result<(), NekrochanError> { + let boards = Self::read_all(ctx) + .await? + .into_iter() + .filter(|board| board.id != self.id) + .collect(); + + update_overboard(ctx, boards).await?; + + query("DELETE FROM bans WHERE board = $1") + .bind(&self.id) + .execute(ctx.db()) + .await?; + + query(&format!("DROP TABLE posts_{}", self.id)) + .execute(ctx.db()) + .await?; + + query("DELETE FROM boards WHERE id = $1") + .bind(&self.id) + .execute(ctx.db()) + .await?; + + ctx.cache().del(format!("boards:{}", self.id)).await?; + ctx.cache().lrem("board_ids", 0, &self.id).await?; + + Ok(()) + } +} + +async fn update_overboard(ctx: &Ctx, boards: Vec) -> Result<(), NekrochanError> { + query("DROP VIEW IF EXISTS overboard") + .execute(ctx.db()) + .await?; + + if boards.is_empty() { + return Ok(()); + } + + let unions = boards + .into_iter() + .map(|board| format!("SELECT * FROM posts_{}", board.id)) + .collect::>() + .join(" UNION "); + + query(&format!("CREATE VIEW overboard AS {unions}")) + .execute(ctx.db()) + .await?; + + Ok(()) +} diff --git a/src/db/cache.rs b/src/db/cache.rs new file mode 100644 index 0000000..b84caff --- /dev/null +++ b/src/db/cache.rs @@ -0,0 +1,100 @@ +use anyhow::Error; +use redis::{cmd, AsyncCommands, JsonAsyncCommands}; +use sha256::digest; +use sqlx::query_as; + +use super::models::{Account, Banner, Board, Post}; +use crate::ctx::Ctx; + +pub async fn init_cache(ctx: &Ctx) -> Result<(), Error> { + cmd("FLUSHDB").query_async(&mut ctx.cache()).await?; + + let accounts: Vec = query_as("SELECT * FROM accounts") + .fetch_all(ctx.db()) + .await?; + + for account in &accounts { + ctx.cache() + .json_set(format!("accounts:{}", account.username), ".", &account) + .await?; + } + + let boards: Vec = query_as("SELECT * FROM boards").fetch_all(ctx.db()).await?; + + for board in &boards { + ctx.cache() + .json_set(format!("boards:{}", board.id), ".", board) + .await?; + + ctx.cache().lpush("board_ids", &board.id).await?; + } + + let banners: Vec = query_as("SELECT * FROM banners") + .fetch_all(ctx.db()) + .await?; + + for banner in &banners { + ctx.cache() + .zadd("banners", serde_json::to_string(banner)?, banner.id) + .await?; + } + + cmd("SORT") + .arg("board_ids") + .arg("ALPHA") + .arg("STORE") + .arg("board_ids") + .query_async(&mut ctx.cache()) + .await?; + + ctx.cache().set("total_threads", 0).await?; + + for board in &boards { + let (thread_count,): (i64,) = query_as(&format!( + "SELECT COUNT(id) FROM posts_{} WHERE thread IS NULL", + board.id + )) + .fetch_one(ctx.db()) + .await?; + + ctx.cache().incr("total_threads", thread_count).await?; + ctx.cache() + .set(format!("board_threads:{}", board.id), thread_count) + .await?; + } + + for board in &boards { + let posts = Post::read_all(ctx, board.id.clone()).await?; + + for post in posts { + let ip_key = format!("by_ip:{}", post.ip); + let content_key = format!( + "by_content:{}", + digest(post.content_nomarkup.to_lowercase()) + ); + + let member = format!("{}/{}", post.board, post.id); + let score = post.created.timestamp_micros(); + + ctx.cache().zadd(ip_key, &member, score).await?; + ctx.cache().zadd(content_key, &member, score).await?; + + if post.thread.is_none() { + let key = format!("last_thread:{}", post.ip); + let last_thread = ctx + .cache() + .get::<_, Option>(&key) + .await? + .unwrap_or_default(); + + let timestamp = post.created.timestamp_micros(); + + if timestamp > last_thread { + ctx.cache().set(key, timestamp).await?; + } + } + } + } + + Ok(()) +} diff --git a/src/db/local_stats.rs b/src/db/local_stats.rs new file mode 100644 index 0000000..136ee99 --- /dev/null +++ b/src/db/local_stats.rs @@ -0,0 +1,30 @@ +use sqlx::query_as; + +use super::models::LocalStats; +use crate::{ctx::Ctx, error::NekrochanError}; + +impl LocalStats { + pub async fn read(ctx: &Ctx) -> Result { + let (post_count,) = query_as( + "SELECT COALESCE(SUM(last_value)::bigint, 0) FROM pg_sequences WHERE sequencename LIKE 'posts_%_id_seq'", + ) + .fetch_one(ctx.db()) + .await?; + + let (file_count, file_size) = query_as( + r#"SELECT COUNT(files), COALESCE(SUM((files->>'size')::bigint)::bigint, 0) FROM ( + SELECT jsonb_array_elements(files) AS files FROM overboard + ) flatten"#, + ) + .fetch_one(ctx.db()) + .await?; + + let stats = Self { + post_count, + file_count, + file_size, + }; + + Ok(stats) + } +} diff --git a/src/db/mod.rs b/src/db/mod.rs new file mode 100755 index 0000000..2bbd7e5 --- /dev/null +++ b/src/db/mod.rs @@ -0,0 +1,10 @@ +pub mod cache; +pub mod models; + +mod account; +mod ban; +mod banner; +mod board; +mod local_stats; +mod newspost; +mod post; diff --git a/src/db/models.rs b/src/db/models.rs new file mode 100755 index 0000000..797ce19 --- /dev/null +++ b/src/db/models.rs @@ -0,0 +1,107 @@ +use std::net::IpAddr; + +use chrono::{DateTime, Utc}; +use ipnetwork::IpNetwork; +use serde::{Deserialize, Serialize}; +use sqlx::{types::Json, FromRow}; + +use crate::cfg::BoardCfg; + +#[derive(FromRow, Serialize, Deserialize, Clone)] +pub struct Account { + pub username: String, + pub password: String, + pub owner: bool, + pub permissions: Json, + pub created: DateTime, +} + +#[derive(FromRow, Serialize, Deserialize, Clone)] +pub struct Board { + pub id: String, + pub name: String, + pub description: String, + pub config: Json, + pub created: DateTime, +} + +#[derive(FromRow, Serialize, Deserialize, Clone)] +pub struct Ban { + pub id: i32, + pub ip_range: IpNetwork, + pub reason: String, + pub board: Option, + pub issued_by: String, + pub appealable: bool, + pub appeal: Option, + pub expires: Option>, + pub created: DateTime, +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct Report { + pub reason: String, + pub reporter_country: String, + pub reporter_ip: IpAddr, +} + +#[derive(FromRow, Serialize, Deserialize, Clone)] +pub struct Post { + pub id: i64, + pub board: String, + pub thread: Option, + pub name: String, + pub user_id: String, + pub tripcode: Option, + pub capcode: Option, + pub email: Option, + pub content: String, + pub content_nomarkup: String, + pub files: Json>, + pub password: String, + pub country: String, + pub ip: IpAddr, + pub bumps: i32, + pub replies: i32, + pub quotes: Vec, + pub sticky: bool, + pub locked: bool, + pub reported: Option>, + pub reports: Json>, + pub bumped: DateTime, + pub created: DateTime, +} + +#[derive(FromRow, Serialize, Deserialize)] +pub struct Banner { + pub id: i32, + pub banner: Json, +} + +#[derive(FromRow)] +pub struct NewsPost { + pub id: i32, + pub title: String, + pub content: String, + pub content_nomarkup: String, + pub author: String, + pub created: DateTime, +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct File { + pub original_name: String, + pub format: String, + pub thumb_format: Option, + pub spoiler: bool, + pub width: u32, + pub height: u32, + pub timestamp: i64, + pub size: usize, +} + +pub struct LocalStats { + pub post_count: i64, + pub file_count: i64, + pub file_size: i64, +} diff --git a/src/db/newspost.rs b/src/db/newspost.rs new file mode 100644 index 0000000..93b2417 --- /dev/null +++ b/src/db/newspost.rs @@ -0,0 +1,74 @@ +use sqlx::{query, query_as}; + +use super::models::NewsPost; +use crate::{ctx::Ctx, error::NekrochanError}; + +impl NewsPost { + pub async fn create( + ctx: &Ctx, + title: String, + content: String, + content_nomarkup: String, + author: String, + ) -> Result { + let newspost = query_as("INSERT INTO news (title, content, content_nomarkup, author) VALUES ($1, $2, $3, $4) RETURNING *") + .bind(title) + .bind(content) + .bind(content_nomarkup) + .bind(author) + .fetch_one(ctx.db()) + .await?; + + Ok(newspost) + } + + pub async fn read(ctx: &Ctx, id: i32) -> Result, NekrochanError> { + let newspost = query_as("SELECT * FROM news WHERE id = $1") + .bind(id) + .fetch_optional(ctx.db()) + .await?; + + Ok(newspost) + } + + pub async fn read_all(ctx: &Ctx) -> Result, NekrochanError> { + let newsposts = query_as("SELECT * FROM news ORDER BY created DESC") + .fetch_all(ctx.db()) + .await?; + + Ok(newsposts) + } + + pub async fn read_latest(ctx: &Ctx) -> Result, NekrochanError> { + let newspost = query_as("SELECT * FROM news ORDER BY created DESC LIMIT 1") + .fetch_optional(ctx.db()) + .await?; + + Ok(newspost) + } + + pub async fn update( + &self, + ctx: &Ctx, + content: String, + content_nomarkup: String, + ) -> Result<(), NekrochanError> { + query("UPDATE news SET content = $1, content_nomarkup = $2 WHERE id = $3") + .bind(content) + .bind(content_nomarkup) + .bind(self.id) + .execute(ctx.db()) + .await?; + + Ok(()) + } + + pub async fn delete(&self, ctx: &Ctx) -> Result<(), NekrochanError> { + query("DELETE FROM news WHERE id = $1") + .bind(self.id) + .execute(ctx.db()) + .await?; + + Ok(()) + } +} diff --git a/src/db/post.rs b/src/db/post.rs new file mode 100755 index 0000000..8dfd198 --- /dev/null +++ b/src/db/post.rs @@ -0,0 +1,594 @@ +use chrono::Utc; +use redis::AsyncCommands; +use sha256::digest; +use sqlx::{query, query_as, types::Json}; +use std::net::IpAddr; + +use super::models::{Board, File, Post, Report}; +use crate::{ + ctx::Ctx, + error::NekrochanError, + live_hub::{PostCreatedMessage, PostRemovedMessage, PostUpdatedMessage}, + GENERIC_PAGE_SIZE, +}; + +impl Post { + #[allow(clippy::too_many_arguments)] + pub async fn create( + ctx: &Ctx, + board: &Board, + thread: Option, + name: String, + tripcode: Option, + capcode: Option, + email: Option, + content: String, + content_nomarkup: String, + files: Vec, + password: String, + country: String, + ip: IpAddr, + bump: bool, + ) -> Result { + let post: Post = query_as(&format!( + r#"INSERT INTO posts_{} + (thread, name, tripcode, capcode, email, content, content_nomarkup, files, password, country, ip) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING *"#, board.id) + ) + .bind(thread) + .bind(name) + .bind(tripcode) + .bind(capcode) + .bind(email) + .bind(content) + .bind(content_nomarkup) + .bind(Json(files)) + .bind(password) + .bind(country) + .bind(ip) + .fetch_one(ctx.db()) + .await?; + + if let Some(thread) = thread { + query(&format!( + "UPDATE posts_{} SET replies = replies + 1 WHERE id = $1", + board.id + )) + .bind(thread) + .execute(ctx.db()) + .await?; + + if bump { + query(&format!( + "UPDATE posts_{} SET bumps = bumps + 1, bumped = CURRENT_TIMESTAMP WHERE id = $1", + board.id + )) + .bind(thread) + .execute(ctx.db()) + .await?; + } + } else { + delete_old_threads(ctx, board).await?; + + ctx.cache().incr("total_threads", 1).await?; + ctx.cache() + .incr(format!("board_threads:{}", board.id), 1) + .await?; + } + + let ip_key = format!("by_ip:{ip}"); + let content_key = format!( + "by_content:{}", + digest(post.content_nomarkup.to_lowercase()) + ); + + let member = format!("{}/{}", board.id, post.id); + let score = post.created.timestamp_micros(); + + ctx.cache().zadd(ip_key, &member, score).await?; + ctx.cache().zadd(content_key, &member, score).await?; + + if thread.is_none() { + ctx.cache() + .set(format!("last_thread:{ip}"), post.created.timestamp_micros()) + .await?; + } + + Ok(post) + } + + pub async fn create_report( + &self, + ctx: &Ctx, + reason: String, + reporter_country: String, + reporter_ip: IpAddr, + ) -> Result<(), NekrochanError> { + let mut reports = self.reports.clone(); + + reports.push(Report { + reason, + reporter_country, + reporter_ip, + }); + + query(&format!( + "UPDATE posts_{} SET reported = CURRENT_TIMESTAMP, reports = $1 WHERE id = $2", + self.board + )) + .bind(reports) + .bind(self.id) + .execute(ctx.db()) + .await?; + + Ok(()) + } + + pub async fn read(ctx: &Ctx, board: String, id: i64) -> Result, NekrochanError> { + let post = query_as(&format!("SELECT * FROM posts_{} WHERE id = $1", board)) + .bind(id) + .fetch_optional(ctx.db()) + .await?; + + Ok(post) + } + + pub async fn read_board_page( + ctx: &Ctx, + board: &Board, + page: i64, + ) -> Result, NekrochanError> { + let posts = query_as(&format!( + r#"SELECT * FROM posts_{} + WHERE thread IS NULL + ORDER BY sticky DESC, bumped DESC + LIMIT $1 + OFFSET $2"#, + board.id + )) + .bind(board.config.0.page_size) + .bind((page - 1) * board.config.0.page_size) + .fetch_all(ctx.db()) + .await?; + + Ok(posts) + } + + pub async fn read_board_catalog(ctx: &Ctx, board: String) -> Result, NekrochanError> { + let posts = query_as(&format!( + r#"SELECT * FROM posts_{board} + WHERE thread IS NULL + ORDER BY sticky DESC, bumped DESC"# + )) + .fetch_all(ctx.db()) + .await?; + + Ok(posts) + } + + pub async fn read_overboard_page(ctx: &Ctx, page: i64) -> Result, NekrochanError> { + let posts = query_as( + r#"SELECT * FROM overboard + WHERE thread IS NULL + ORDER BY bumped DESC + LIMIT $1 + OFFSET $2"#, + ) + .bind(GENERIC_PAGE_SIZE) + .bind((page - 1) * GENERIC_PAGE_SIZE) + .fetch_all(ctx.db()) + .await?; + + Ok(posts) + } + + pub async fn read_overboard_catalog(ctx: &Ctx) -> Result, NekrochanError> { + let posts = query_as( + r#"SELECT * FROM overboard + WHERE thread IS NULL + ORDER BY bumped DESC"#, + ) + .fetch_all(ctx.db()) + .await?; + + Ok(posts) + } + + pub async fn read_reports_page(ctx: &Ctx, page: i64) -> Result, NekrochanError> { + let posts = query_as( + r#"SELECT * FROM overboard + WHERE reports != '[]'::jsonb + ORDER BY jsonb_array_length(reports), reported DESC + LIMIT $1 + OFFSET $2"#, + ) + .bind(GENERIC_PAGE_SIZE) + .bind((page - 1) * GENERIC_PAGE_SIZE) + .fetch_all(ctx.db()) + .await?; + + Ok(posts) + } + + pub async fn read_ip_page( + ctx: &Ctx, + ip: IpAddr, + page: i64, + ) -> Result, NekrochanError> { + let posts = query_as( + r#"SELECT * FROM overboard + WHERE ip = $1 + ORDER BY created DESC + LIMIT $2 + OFFSET $3"#, + ) + .bind(ip) + .bind(GENERIC_PAGE_SIZE) + .bind((page - 1) * GENERIC_PAGE_SIZE) + .fetch_all(ctx.db()) + .await?; + + Ok(posts) + } + + pub async fn read_replies(&self, ctx: &Ctx) -> Result, NekrochanError> { + let replies = query_as(&format!( + "SELECT * FROM posts_{} WHERE thread = $1 ORDER BY sticky DESC, created ASC", + self.board + )) + .bind(self.id) + .fetch_all(ctx.db()) + .await?; + + Ok(replies) + } + + pub async fn read_replies_after( + &self, + ctx: &Ctx, + last: i64, + ) -> Result, NekrochanError> { + let replies = query_as(&format!( + "SELECT * FROM posts_{} WHERE thread = $1 AND id > $2 ORDER BY created ASC", + self.board + )) + .bind(self.id) + .bind(last) + .fetch_all(ctx.db()) + .await?; + + Ok(replies) + } + + pub async fn read_all(ctx: &Ctx, board: String) -> Result, NekrochanError> { + let posts = query_as(&format!("SELECT * FROM posts_{board}")) + .fetch_all(ctx.db()) + .await?; + + Ok(posts) + } + + pub async fn read_all_overboard(ctx: &Ctx) -> Result, NekrochanError> { + let posts = query_as("SELECT * FROM overboard") + .fetch_all(ctx.db()) + .await?; + + Ok(posts) + } + + pub async fn read_by_query( + ctx: &Ctx, + board: &Board, + query: String, + page: i64, + ) -> Result, NekrochanError> { + let posts = query_as(&format!( + "SELECT * FROM posts_{} WHERE LOWER(content_nomarkup) LIKE LOWER($1) ORDER BY created DESC LIMIT $2 OFFSET $3", + board.id + )) + .bind(format!("%{query}%")) + .bind(board.config.0.page_size) + .bind((page - 1) * board.config.0.page_size) + .fetch_all(ctx.db()) + .await?; + + Ok(posts) + } + + pub async fn read_by_query_overboard( + ctx: &Ctx, + query: String, + page: i64, + ) -> Result, NekrochanError> { + let posts = + query_as("SELECT * FROM overboard WHERE LOWER(content_nomarkup) LIKE LOWER($1) ORDER BY created DESC LIMIT $2 OFFSET $3") + .bind(format!("%{query}%")) + .bind(GENERIC_PAGE_SIZE) + .bind((page - 1) * GENERIC_PAGE_SIZE) + .fetch_all(ctx.db()) + .await?; + + Ok(posts) + } + + pub async fn update_user_id(&self, ctx: &Ctx, user_id: String) -> Result<(), NekrochanError> { + let post = query_as(&format!( + "UPDATE posts_{} SET user_id = $1 WHERE id = $2 RETURNING *", + self.board, + )) + .bind(user_id) + .bind(self.id) + .fetch_one(ctx.db()) + .await?; + + ctx.hub().send(PostCreatedMessage { post }).await?; + + Ok(()) + } + + pub async fn update_sticky(&self, ctx: &Ctx) -> Result<(), NekrochanError> { + let post = query_as(&format!( + "UPDATE posts_{} SET sticky = NOT sticky WHERE id = $1 RETURNING *", + self.board + )) + .bind(self.id) + .fetch_one(ctx.db()) + .await?; + + ctx.hub().send(PostUpdatedMessage { post }).await?; + + Ok(()) + } + + pub async fn update_lock(&self, ctx: &Ctx) -> Result<(), NekrochanError> { + let post = query_as(&format!( + "UPDATE posts_{} SET locked = NOT locked WHERE id = $1 RETURNING *", + self.board + )) + .bind(self.id) + .fetch_one(ctx.db()) + .await?; + + ctx.hub().send(PostUpdatedMessage { post }).await?; + + Ok(()) + } + + pub async fn update_content( + &self, + ctx: &Ctx, + content: String, + content_nomarkup: String, + ) -> Result<(), NekrochanError> { + let post = query_as(&format!( + "UPDATE posts_{} SET content = $1, content_nomarkup = $2 WHERE id = $3 RETURNING *", + self.board + )) + .bind(content) + .bind(&content_nomarkup) + .bind(self.id) + .fetch_optional(ctx.db()) + .await?; + + let Some(post) = post else { return Ok(()) }; + + let old_key = format!( + "by_content:{}", + digest(self.content_nomarkup.to_lowercase()) + ); + let new_key = format!("by_content:{}", digest(content_nomarkup.to_lowercase())); + let member = format!("{}/{}", self.board, self.id); + let score = Utc::now().timestamp_micros(); + + ctx.cache().zrem(old_key, &member).await?; + ctx.cache().zadd(new_key, &member, score).await?; + ctx.hub().send(PostUpdatedMessage { post }).await?; + + Ok(()) + } + + pub async fn update_quotes(&self, ctx: &Ctx, id: i64) -> Result<(), NekrochanError> { + let post = query_as(&format!( + "UPDATE posts_{} SET quotes = array_append(quotes, $1) WHERE id = $2 RETURNING *", + self.board + )) + .bind(id) + .bind(self.id) + .fetch_one(ctx.db()) + .await?; + + ctx.hub().send(PostUpdatedMessage { post }).await?; + + Ok(()) + } + + pub async fn update_spoiler(&self, ctx: &Ctx) -> Result<(), NekrochanError> { + let mut files = self.files.clone(); + + for file in files.iter_mut() { + file.spoiler = !file.spoiler; + } + + let post = query_as(&format!( + "UPDATE posts_{} SET files = $1 WHERE id = $2 RETURNING *", + self.board + )) + .bind(Json(files)) + .bind(self.id) + .fetch_one(ctx.db()) + .await?; + + ctx.hub().send(PostUpdatedMessage { post }).await?; + + Ok(()) + } + + pub async fn delete(&self, ctx: &Ctx) -> Result<(), NekrochanError> { + let to_be_deleted: Vec = query_as(&format!( + "SELECT * FROM posts_{} WHERE id = $1 OR thread = $1 ORDER BY id ASC", + self.board + )) + .bind(self.id) + .fetch_all(ctx.db()) + .await?; + + for post in &to_be_deleted { + for file in post.files.iter() { + file.delete().await; + } + + let id = post.id; + let url = post.post_url(); + + let live_quote = format!(">>{id}"); + let dead_quote = format!(">>{id}"); + + let posts = query_as(&format!( + "UPDATE posts_{} SET content = REPLACE(content, $1, $2) WHERE content LIKE '%{}%' RETURNING *", + self.board, live_quote + )) + .bind(live_quote) + .bind(dead_quote) + .fetch_all(ctx.db()) + .await?; + + for post in posts { + ctx.hub().send(PostUpdatedMessage { post }).await?; + } + + let posts = query_as(&format!( + "UPDATE posts_{} SET quotes = array_remove(quotes, $1) WHERE $1 = ANY(quotes) RETURNING *", + self.board + )) + .bind(id) + .fetch_all(ctx.db()) + .await?; + + for post in posts { + ctx.hub().send(PostUpdatedMessage { post }).await?; + } + + let ip_key = format!("by_ip:{}", post.ip); + let content_key = format!( + "by_content:{}", + digest(post.content_nomarkup.to_lowercase()) + ); + + let member = format!("{}/{}", post.board, post.id); + + ctx.cache().zrem(ip_key, &member).await?; + ctx.cache().zrem(content_key, &member).await?; + ctx.hub() + .send(PostRemovedMessage { post: post.clone() }) + .await?; + } + + let in_list = to_be_deleted + .iter() + .map(|post| (post.id)) + .collect::>(); + + query(&format!( + "DELETE FROM posts_{} WHERE id = ANY($1)", + self.board + )) + .bind(&in_list) + .execute(ctx.db()) + .await?; + + if let Some(thread) = self.thread { + query(&format!( + "UPDATE posts_{} SET replies = replies - 1 WHERE id = $1", + self.board + )) + .bind(thread) + .execute(ctx.db()) + .await?; + } else { + ctx.cache().decr("total_threads", 1).await?; + ctx.cache() + .decr(format!("board_threads:{}", self.board), 1) + .await?; + } + + Ok(()) + } + + pub async fn delete_files(&self, ctx: &Ctx) -> Result<(), NekrochanError> { + for file in &self.files.0 { + file.delete().await; + } + + query(&format!( + "UPDATE posts_{} SET files = '[]'::jsonb WHERE id = $1", + self.board + )) + .bind(self.id) + .execute(ctx.db()) + .await?; + + ctx.hub() + .send(PostUpdatedMessage { post: self.clone() }) + .await?; + + Ok(()) + } + + pub async fn delete_reports(&self, ctx: &Ctx) -> Result<(), NekrochanError> { + query(&format!( + "UPDATE posts_{} SET reported = NULL, reports = '[]'::jsonb WHERE id = $1", + self.board + )) + .bind(self.id) + .execute(ctx.db()) + .await?; + + Ok(()) + } +} + +impl Post { + pub fn post_url(&self) -> String { + format!( + "/boards/{}/{}#{}", + self.board, + self.thread.unwrap_or(self.id), + self.id + ) + } + + pub fn post_url_notarget(&self) -> String { + format!("/boards/{}/{}", self.board, self.id) + } + + pub fn thread_url(&self) -> String { + format!("/boards/{}/{}", self.board, self.thread.unwrap_or(self.id),) + } +} + +async fn delete_old_threads(ctx: &Ctx, board: &Board) -> Result<(), NekrochanError> { + let old_threads: Vec = query_as(&format!( + r#"SELECT * FROM posts_{} + WHERE thread IS NULL AND id NOT IN ( + SELECT id + FROM ( + SELECT id + FROM posts_{} + WHERE thread IS NULL + ORDER BY sticky DESC, bumped DESC + LIMIT $1 + ) catty + )"#, + board.id, board.id + )) + .bind(board.config.0.page_size * board.config.0.page_count) + .fetch_all(ctx.db()) + .await?; + + for thread in &old_threads { + thread.delete(ctx).await?; + } + + Ok(()) +} diff --git a/src/error.rs b/src/error.rs new file mode 100755 index 0000000..97f4b5e --- /dev/null +++ b/src/error.rs @@ -0,0 +1,269 @@ +use actix_web::{http::StatusCode, ResponseError}; +use log::error; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum NekrochanError { + #[error("Účet '{}' neexistuje.", .0)] + AccountNotFound(String), + #[error("Tento ban už byl odvolán.")] + AlreadyAppealedError, + #[error("Odvolání můsí mít 1-1000 znaků.")] + BanAppealFormatError, + #[error("Žádný takový ban pro tuto IP adresu neexistuje.")] + BanNotFound, + #[error("Důvod banu musí mít 1-200 znaků.")] + BanReasonFormatError, + #[error("Nástěnka /{}/ je uzamčená.", .0)] + BoardLockError(String), + #[error("Jméno nástěnky musí mít 1-32 znaků.")] + BoardNameFormatError, + #[error("Nástěnka /{}/ neexistuje.", .0)] + BoardNotFound(String), + #[error("Capcode nesmí mít více než 32 znaků.")] + CapcodeFormatError, + #[error("Obsah nesmí mít více než 10000 znaků.")] + ContentFormatError, + #[error("Popis nesmí mít více než 128 znaků.")] + DescriptionFormatError, + #[error("E-mail nesmí mít více než 256 znaků.")] + EmailFormatError, + #[error("Příspěvek musí mít obsah nebo soubor.")] + EmptyPostError, + #[error("Chyba při zpracovávání souboru '{}': {}", .0, .1)] + FileError(String, &'static str), + #[error("Maximální počet souborů na této nástěnce je {}.", .0)] + FileLimitError(usize), + #[error("Tvůj příspěvek vypadá jako spam.")] + FloodError, + #[error("Domovní stránka vznikne po vytvoření nástěnky.")] + HomePageError, + #[error("ID musí mít 1-16 znaků a obsahovat pouze alfanumerické znaky.")] + IdFormatError, + #[error("Nesprávné řešení CAPTCHA.")] + IncorrectCaptchaError, + #[error("Nesprávné přihlašovací údaje.")] + IncorrectCredentialError, + #[error("Nesprávné heslo pro příspěvek #{}.", .0)] + IncorrectPasswordError(i64), + #[error("Nedostatečná oprávnění.")] + InsufficientPermissionError, + #[error("Server se připojil k 41 procentům.")] + InternalError, + #[error("Neplatný autentizační token. Vymaž soubory cookie.")] + InvalidAuthError, + #[error("Tato CAPTCHA vypršela nebo neexistuje.")] + InvalidCaptchaError, + #[error("Neplatná strana.")] + InvalidPageError, + #[error("Tento příspěvek není vlákno.")] + IsReplyError, + #[error("Obsah musí mít 1-20000 znaků.")] + NewsContentFormatError, + #[error("Titulek musí mít 1-100 znaků.")] + NewsTitleFormatError, + #[error("Tato nástěnka nevyžaduje CAPTCHA.")] + NoCaptchaError, + #[error("Příspěvek musí mít obsah.")] + NoContentError, + #[error("Příspěvek musí mít soubor.")] + NoFileError, + #[error("Nebyly vybrány žádné příspěvky.")] + NoPostsError, + #[error("Pro přístup se musíš přihlásit.")] + NotLoggedInError, + #[error("Nadnástěnka nebyla inicializována.")] + OverboardError, + #[error("Účet vlastníka nemůže být vymazán.")] + OwnerDeletionError, + #[error("Stránka {} neexistuje", .0)] + PageNotFound(String), + #[error("Heslo musí mít alespoň 8 znaků.")] + PasswordFormatError, + #[error("Jméno nesmí mít více než 32 znaků.")] + PostNameFormatError, + #[error("Příspěvek /{}/{} neexistuje.", .0, .1)] + PostNotFound(String, i64), + #[error("Hledaný termín musí mít 1-256 znaků.")] + QueryFormatError, + #[error("Vlákno dosáhlo limitu odpovědí.")] + ReplyLimitError, + #[error("Hlášení můsí mít 1-200 znaků.")] + ReportFormatError, + #[error("Na této nástěnce se musí vyplnit CAPTCHA.")] + RequiredCaptchaError, + #[error("Toto vlákno je uzamčené.")] + ThreadLockError, + #[error("Tento ban nelze odvolat.")] + UnappealableError, + #[error("Uživatelské jméno musí mít 1-32 znaků.")] + UsernameFormatError, +} + +impl From for NekrochanError { + fn from(e: actix::MailboxError) -> Self { + error!("Internal server error: {e:#?}"); + Self::InternalError + } +} + +impl From for NekrochanError { + fn from(e: actix_web::Error) -> Self { + error!("Internal server error: {e:#?}"); + Self::InternalError + } +} + +impl From for NekrochanError { + fn from(e: askama::Error) -> Self { + error!("Internal server error: {e:#?}"); + Self::InternalError + } +} + +impl From for NekrochanError { + fn from(e: ipnetwork::IpNetworkError) -> Self { + error!("Internal server error: {e:#?}"); + Self::InternalError + } +} + +impl From for NekrochanError { + fn from(e: jsonwebtoken::errors::Error) -> Self { + error!("Internal server error: {e:#?}"); + Self::InternalError + } +} + +impl From for NekrochanError { + fn from(e: pwhash::error::Error) -> Self { + error!("Internal server error: {e:#?}"); + Self::InternalError + } +} + +impl From for NekrochanError { + fn from(e: regex::Error) -> Self { + error!("Internal server error: {e:#?}"); + Self::InternalError + } +} + +impl From for NekrochanError { + fn from(e: redis::RedisError) -> Self { + error!("Internal server error: {e:#?}"); + Self::InternalError + } +} + +impl From for NekrochanError { + fn from(e: serde_json::Error) -> Self { + error!("Internal server error: {e:#?}"); + Self::InternalError + } +} + +impl From for NekrochanError { + fn from(e: serde_qs::Error) -> Self { + error!("Internal server error: {e:#?}"); + Self::InternalError + } +} + +impl From for NekrochanError { + fn from(e: sqlx::Error) -> Self { + let overboard_err = match e.as_database_error() { + Some(e) => e.message() == "relation \"overboard\" does not exist", + None => false, + }; + + if overboard_err { + Self::OverboardError + } else { + error!("{e:#?}"); + Self::InternalError + } + } +} + +impl From for NekrochanError { + fn from(e: std::io::Error) -> Self { + error!("Internal server error: {e:#?}"); + Self::InternalError + } +} + +impl From for NekrochanError { + fn from(e: std::net::AddrParseError) -> Self { + error!("Internal server error: {e:#?}"); + Self::InternalError + } +} + +impl From> for NekrochanError { + fn from(_: std::sync::PoisonError) -> Self { + error!("Some Mutex or Lock got poisoned or something"); + Self::InternalError + } +} + +impl From for NekrochanError { + fn from(e: tokio::task::JoinError) -> Self { + error!("Internal server error: {e:#?}"); + Self::InternalError + } +} + +impl ResponseError for NekrochanError { + fn status_code(&self) -> StatusCode { + match self { + NekrochanError::AccountNotFound(_) => StatusCode::NOT_FOUND, + NekrochanError::AlreadyAppealedError => StatusCode::BAD_REQUEST, + NekrochanError::BanAppealFormatError => StatusCode::BAD_REQUEST, + NekrochanError::BanNotFound => StatusCode::NOT_FOUND, + NekrochanError::BanReasonFormatError => StatusCode::BAD_REQUEST, + NekrochanError::BoardLockError(_) => StatusCode::FORBIDDEN, + NekrochanError::BoardNameFormatError => StatusCode::BAD_REQUEST, + NekrochanError::BoardNotFound(_) => StatusCode::NOT_FOUND, + NekrochanError::CapcodeFormatError => StatusCode::BAD_REQUEST, + NekrochanError::ContentFormatError => StatusCode::BAD_REQUEST, + NekrochanError::DescriptionFormatError => StatusCode::BAD_REQUEST, + NekrochanError::EmailFormatError => StatusCode::BAD_REQUEST, + NekrochanError::EmptyPostError => StatusCode::BAD_REQUEST, + NekrochanError::FileError(_, _) => StatusCode::UNPROCESSABLE_ENTITY, + NekrochanError::FileLimitError(_) => StatusCode::BAD_REQUEST, + NekrochanError::FloodError => StatusCode::TOO_MANY_REQUESTS, + NekrochanError::HomePageError => StatusCode::NOT_FOUND, + NekrochanError::IdFormatError => StatusCode::BAD_REQUEST, + NekrochanError::IncorrectCaptchaError => StatusCode::UNAUTHORIZED, + NekrochanError::IncorrectCredentialError => StatusCode::UNAUTHORIZED, + NekrochanError::IncorrectPasswordError(_) => StatusCode::UNAUTHORIZED, + NekrochanError::InsufficientPermissionError => StatusCode::FORBIDDEN, + NekrochanError::InternalError => StatusCode::INTERNAL_SERVER_ERROR, + NekrochanError::InvalidAuthError => StatusCode::UNAUTHORIZED, + NekrochanError::InvalidCaptchaError => StatusCode::BAD_REQUEST, + NekrochanError::InvalidPageError => StatusCode::BAD_REQUEST, + NekrochanError::IsReplyError => StatusCode::BAD_REQUEST, + NekrochanError::NewsContentFormatError => StatusCode::BAD_REQUEST, + NekrochanError::NewsTitleFormatError => StatusCode::BAD_REQUEST, + NekrochanError::NoCaptchaError => StatusCode::NOT_FOUND, + NekrochanError::NoContentError => StatusCode::BAD_REQUEST, + NekrochanError::NoFileError => StatusCode::BAD_REQUEST, + NekrochanError::NoPostsError => StatusCode::BAD_REQUEST, + NekrochanError::NotLoggedInError => StatusCode::UNAUTHORIZED, + NekrochanError::OverboardError => StatusCode::INTERNAL_SERVER_ERROR, + NekrochanError::OwnerDeletionError => StatusCode::FORBIDDEN, + NekrochanError::PageNotFound(_) => StatusCode::NOT_FOUND, + NekrochanError::PasswordFormatError => StatusCode::BAD_REQUEST, + NekrochanError::PostNameFormatError => StatusCode::BAD_REQUEST, + NekrochanError::PostNotFound(_, _) => StatusCode::NOT_FOUND, + NekrochanError::QueryFormatError => StatusCode::BAD_REQUEST, + NekrochanError::ReplyLimitError => StatusCode::FORBIDDEN, + NekrochanError::ReportFormatError => StatusCode::BAD_REQUEST, + NekrochanError::RequiredCaptchaError => StatusCode::UNAUTHORIZED, + NekrochanError::ThreadLockError => StatusCode::FORBIDDEN, + NekrochanError::UnappealableError => StatusCode::BAD_REQUEST, + NekrochanError::UsernameFormatError => StatusCode::BAD_REQUEST, + } + } +} diff --git a/src/files.rs b/src/files.rs new file mode 100755 index 0000000..a3b9c98 --- /dev/null +++ b/src/files.rs @@ -0,0 +1,299 @@ +use actix_multipart::form::tempfile::TempFile; +use chrono::Utc; +use std::process::Command; +use tokio::{ + fs::{copy, remove_file}, + task::spawn_blocking, +}; + +use crate::{cfg::Cfg, db::models::File, error::NekrochanError}; + +impl File { + pub async fn new( + cfg: &Cfg, + temp_file: TempFile, + spoiler: bool, + thumb: bool, + ) -> Result { + let original_name = temp_file.file_name.unwrap_or_else(|| "unknown".into()); + + let mime = temp_file + .content_type + .ok_or(NekrochanError::FileError( + original_name.clone(), + "žádný mime typ", + ))? + .to_string(); + + let (video, format) = match mime.as_str() { + "image/jpeg" => (false, "jpg"), + "image/pjpeg" => (false, "jpg"), + "image/png" => (false, "png"), + "image/bmp" => (false, "bmp"), + "image/gif" => (false, "gif"), + "image/webp" => (false, "webp"), + "image/apng" => (false, "apng"), + "video/mpeg" => (true, "mpeg"), + "video/quicktime" => (true, "mov"), + "video/mp4" => (true, "mp4"), + "video/webm" => (true, "webm"), + "video/x-matroska" => (true, "mkv"), + "video/ogg" => (true, "ogg"), + _ => { + return Err(NekrochanError::FileError( + original_name, + "nepodporovaný formát", + )) + } + }; + + if video && !cfg.files.videos { + return Err(NekrochanError::FileError( + original_name, + "videa nejsou podporovaná", + )); + } + + let size = temp_file.size; + + if size / 1_000_000 > cfg.files.max_size_mb { + return Err(NekrochanError::FileError( + original_name, + "soubor je příliš velký", + )); + } + + let timestamp = Utc::now().timestamp_micros(); + let format = format.to_owned(); + + let (thumb_format, thumb_name) = if thumb { + let format = if video { "png".into() } else { format.clone() }; + + (Some(format.clone()), Some(format!("{timestamp}.{format}"))) + } else { + (None, None) + }; + + let path = temp_file.file.path().to_string_lossy().to_string(); + + let (width, height) = if video { + process_video(cfg, original_name.clone(), path.clone(), thumb_name).await? + } else { + process_image(cfg, original_name.clone(), path.clone(), thumb_name).await? + }; + + copy(path, format!("./uploads/{timestamp}.{format}")).await?; + + let file = File { + original_name, + format, + thumb_format, + spoiler, + width, + height, + timestamp, + size, + }; + + Ok(file) + } + + pub async fn delete(&self) { + remove_file(format!("./uploads/{}.{}", self.timestamp, self.format)) + .await + .ok(); + + if let Some(thumb_format) = &self.thumb_format { + remove_file(format!( + "./uploads/thumb/{}.{}", + self.timestamp, thumb_format + )) + .await + .ok(); + } + } + + pub fn file_url(&self) -> String { + format!("/uploads/{}.{}", self.timestamp, self.format) + } + + pub fn thumb_url(&self) -> String { + if self.spoiler { + "/static/spoiler.png".into() + } else if let Some(thumb_format) = &self.thumb_format { + format!("/uploads/thumb/{}.{}", self.timestamp, thumb_format) + } else { + self.file_url() + } + } +} + +async fn process_image( + cfg: &Cfg, + original_name: String, + path: String, + thumb_name: Option, +) -> Result<(u32, u32), NekrochanError> { + let path_ = path.clone(); + + let identify_out = spawn_blocking(move || { + Command::new("identify") + .args(["-format", "%wx%h", &format!("{path_}[0]")]) + .output() + }) + .await??; + + let invalid_dimensions = "imagemagick vrátil neplatné rozměry"; + let out_string = String::from_utf8_lossy(&identify_out.stdout); + + let (width, height) = out_string + .trim() + .split_once('x') + .ok_or(NekrochanError::FileError( + original_name.clone(), + invalid_dimensions, + ))?; + + let (width, height) = ( + width + .parse() + .map_err(|_| NekrochanError::FileError(original_name.clone(), invalid_dimensions))?, + height + .parse() + .map_err(|_| NekrochanError::FileError(original_name.clone(), invalid_dimensions))?, + ); + + if width > cfg.files.max_width || height > cfg.files.max_height { + return Err(NekrochanError::FileError( + original_name, + "rozměry obrázku jsou příliš velké", + )); + } + + let Some(thumb_name) = thumb_name else { + return Ok((width, height)); + }; + + let thumb_size = cfg.files.thumb_size; + + let output = spawn_blocking(move || { + Command::new("convert") + .arg(path) + .arg("-coalesce") + .arg("-thumbnail") + .arg(&format!("{thumb_size}x{thumb_size}>")) + .arg(&format!("./uploads/thumb/{thumb_name}")) + .output() + }) + .await??; + + if !output.status.success() { + println!("{}", String::from_utf8_lossy(&output.stderr)); + + return Err(NekrochanError::FileError( + original_name, + "nepodařilo se vytvořit náhled obrázku", + )); + } + + Ok((width, height)) +} + +async fn process_video( + cfg: &Cfg, + original_name: String, + path: String, + thumb_name: Option, +) -> Result<(u32, u32), NekrochanError> { + let path_ = path.clone(); + + let ffprobe_out = spawn_blocking(move || { + Command::new("ffprobe") + .args([ + "-v", + "error", + "-select_streams", + "v:0", + "-show_entries", + "stream=width,height", + "-of", + "csv=s=x:p=0", + &path_, + ]) + .output() + }) + .await??; + + if !ffprobe_out.status.success() { + return Err(NekrochanError::FileError( + original_name, + "nepodařilo se získat rozměry videa", + )); + } + + let invalid_dimensions = "ffmpeg vrátil neplatné rozměry"; + let out_string = String::from_utf8_lossy(&ffprobe_out.stdout); + + let (width, height) = out_string + .trim() + .split_once('x') + .ok_or(NekrochanError::FileError( + original_name.clone(), + invalid_dimensions, + ))?; + + let (width, height) = ( + width + .parse() + .map_err(|_| NekrochanError::FileError(original_name.clone(), invalid_dimensions))?, + height + .parse() + .map_err(|_| NekrochanError::FileError(original_name.clone(), invalid_dimensions))?, + ); + + if width > cfg.files.max_width || height > cfg.files.max_height { + return Err(NekrochanError::FileError( + original_name, + "rozměry videa jsou příliš velké", + )); + } + + let Some(thumb_name) = thumb_name else { + return Ok((width, height)); + }; + + let thumb_size = cfg.files.thumb_size; + + let output = spawn_blocking(move || { + Command::new("ffmpeg") + .args([ + "-i", + &path, + "-ss", + "00:00:00.50", + "-vframes", + "1", + "-vf", + &format!( + "scale={}", + if width > height { + format!("{thumb_size}:-2") + } else { + format!("-2:{thumb_size}") + } + ), + &format!("./uploads/thumb/{thumb_name}"), + ]) + .output() + }) + .await??; + + if !output.status.success() { + return Err(NekrochanError::FileError( + original_name, + "nepodařilo se vytvořit náhled videa", + )); + } + + Ok((width, height)) +} diff --git a/src/filters.rs b/src/filters.rs new file mode 100644 index 0000000..3ba9a5c --- /dev/null +++ b/src/filters.rs @@ -0,0 +1,87 @@ +use chrono::{DateTime, Locale, TimeZone, Utc}; +use chrono_tz::Europe::Prague; +use lazy_static::lazy_static; +use regex::{Captures, Regex}; +use std::{collections::HashSet, fmt::Display}; + +use crate::markup::SPOILER_REGEX; + +lazy_static! { + static ref MARKUP_QUOTE_REGEX: Regex = + Regex::new(r#">>(\d+)<\/a>"#).unwrap(); +} + +pub fn czech_datetime(utc: &DateTime) -> askama::Result { + let time = Prague.from_utc_datetime(&utc.naive_utc()); + + let time = time + .format_localized("%d.%m.%Y (%a) %H:%M:%S", Locale::cs_CZ) + .to_string(); + + Ok(time) +} + +pub fn czech_plural(plurals: &str, count: impl Display) -> askama::Result { + let plurals = plurals.split('|').collect::>(); + let count = count.to_string().parse::().unwrap(); + + let one = plurals[0]; + let few = plurals[1]; + let other = plurals[2]; + + if count == 1 { + Ok(one.into()) + } else if count < 5 && count != 0 { + Ok(few.into()) + } else { + Ok(other.into()) + } +} + +pub fn inline_post(input: impl Display) -> askama::Result { + let input = input.to_string(); + + if input.is_empty() { + return Ok("(bez obsahu)".into()); + } + + let collapsed = input.split_whitespace().collect::>().join(" "); + let spoilered = SPOILER_REGEX + .replace_all(&collapsed, "(spoiler)") + .to_string(); + + let truncated = askama::filters::truncate(spoilered, 64)?; + + Ok(truncated) +} + +pub fn get_page(input: &usize, page_size: &i64) -> askama::Result { + let page = crate::paginate(*page_size, *input as i64); + + Ok(page) +} + +pub fn add_yous( + input: impl Display, + board: &String, + yous: &HashSet, +) -> askama::Result { + let input = input.to_string(); + + let output = MARKUP_QUOTE_REGEX.replace_all(&input, |captures: &Captures| { + let quote = &captures[0]; + let id = &captures[1]; + + format!( + "{}{}", + quote, + if yous.contains(&format!("{board}/{id}")) { + " (Ty)" + } else { + "" + } + ) + }); + + Ok(output.to_string()) +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100755 index 0000000..8d45eb7 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,61 @@ +use askama::Template; +use db::models::{Board, Post}; +use error::NekrochanError; +use web::tcx::TemplateCtx; + +const GENERIC_PAGE_SIZE: i64 = 10; + +pub mod auth; +pub mod cfg; +pub mod ctx; +pub mod db; +pub mod error; +pub mod files; +pub mod filters; +pub mod live_hub; +pub mod live_session; +pub mod markup; +pub mod perms; +pub mod qsform; +pub mod schedule; +pub mod trip; +pub mod web; + +pub fn paginate(page_size: i64, count: i64) -> i64 { + let pages = count / page_size + (count % page_size).signum(); + + if pages == 0 { + 1 + } else { + pages + } +} + +pub fn check_page( + page: i64, + pages: i64, + page_limit: impl Into>, +) -> Result<(), NekrochanError> { + if page <= 0 || (page > pages && page != 1) { + return Err(NekrochanError::InvalidPageError); + } + + if let Some(page_limit) = page_limit.into() { + if page > page_limit { + return Err(NekrochanError::InvalidPageError); + } + } + + Ok(()) +} + +#[derive(Template)] +#[template( + ext = "html", + source = "{% import \"./macros/post.html\" as post %}{% call post::post(board, post, post.thread.is_some()) %}" +)] +pub struct PostTemplate<'a> { + tcx: &'a TemplateCtx, + board: &'a Board, + post: &'a Post, +} diff --git a/src/live_hub.rs b/src/live_hub.rs new file mode 100644 index 0000000..a080cee --- /dev/null +++ b/src/live_hub.rs @@ -0,0 +1,272 @@ +use actix::{Actor, Context, Handler, Message, Recipient}; +use askama::Template; +use redis::Connection; +use serde_json::json; +use std::collections::HashMap; +use uuid::Uuid; + +use crate::{ + db::models::{Board, Post}, + web::tcx::TemplateCtx, + PostTemplate, +}; + +#[derive(Message)] +#[rtype(result = "()")] +pub enum SessionMessage { + Data(String), + Stop, +} + +#[derive(Message)] +#[rtype(result = "()")] +pub struct ConnectMessage { + pub uuid: Uuid, + pub thread: (String, i64), + pub tcx: TemplateCtx, + pub recv: Recipient, +} + +#[derive(Message)] +#[rtype(result = "()")] +pub struct DisconnectMessage { + pub uuid: Uuid, + pub thread: (String, i64), +} + +#[derive(Message)] +#[rtype(result = "()")] +pub struct PostCreatedMessage { + pub post: Post, +} + +#[derive(Message)] +#[rtype(result = "()")] +pub struct TargetedPostCreatedMessage { + pub uuid: Uuid, + pub post: Post, +} + +#[derive(Message)] +#[rtype(result = "()")] +pub struct PostUpdatedMessage { + pub post: Post, +} + +#[derive(Message)] +#[rtype(result = "()")] +pub struct PostRemovedMessage { + pub post: Post, +} + +pub struct LiveHub { + pub cache: Connection, + pub recv_by_uuid: HashMap)>, + pub recv_by_thread: HashMap<(String, i64), Vec>, +} + +impl LiveHub { + pub fn new(cache: Connection) -> Self { + Self { + cache, + recv_by_uuid: HashMap::new(), + recv_by_thread: HashMap::new(), + } + } +} + +impl Actor for LiveHub { + type Context = Context; +} + +impl Handler for LiveHub { + type Result = (); + + fn handle(&mut self, msg: ConnectMessage, _: &mut Self::Context) -> Self::Result { + self.recv_by_uuid.insert(msg.uuid, (msg.tcx, msg.recv)); + + match self.recv_by_thread.get_mut(&msg.thread) { + Some(vec) => { + vec.push(msg.uuid); + } + None => { + self.recv_by_thread.insert(msg.thread, vec![msg.uuid]); + } + } + } +} + +impl Handler for LiveHub { + type Result = (); + + fn handle(&mut self, msg: DisconnectMessage, _: &mut Self::Context) -> Self::Result { + self.recv_by_uuid.remove(&msg.uuid); + + let recv_by_thread = match self.recv_by_thread.get_mut(&msg.thread) { + Some(recv_by_thread) => recv_by_thread, + None => return, + }; + + *recv_by_thread = recv_by_thread + .iter() + .filter(|uuid| **uuid != msg.uuid) + .map(Uuid::clone) + .collect(); + + if recv_by_thread.is_empty() { + self.recv_by_thread.remove(&msg.thread); + } + } +} + +impl Handler for LiveHub { + type Result = (); + + fn handle(&mut self, msg: PostCreatedMessage, _: &mut Self::Context) -> Self::Result { + let post = msg.post; + + let uuids = self + .recv_by_thread + .get(&(post.board.clone(), post.thread.unwrap_or(post.id))); + + let uuids = match uuids { + Some(uuids) => uuids, + None => return, + }; + + let Ok(Some(board)) = Board::read_sync(&mut self.cache, post.board.clone()) else { + return; + }; + + for uuid in uuids { + let Some((tcx, recv)) = self.recv_by_uuid.get_mut(uuid) else { + continue; + }; + + tcx.update_yous(&mut self.cache).ok(); + + let tcx = &tcx; + let board = &board; + let post = &post; + let id = post.id; + + let html = PostTemplate { tcx, board, post } + .render() + .unwrap_or_default(); + + recv.do_send(SessionMessage::Data( + json!({ "type": "created", "id": id, "html": html }).to_string(), + )); + } + } +} + +impl Handler for LiveHub { + type Result = (); + + fn handle(&mut self, msg: TargetedPostCreatedMessage, _: &mut Self::Context) -> Self::Result { + let post = msg.post; + + let Ok(Some(board)) = Board::read_sync(&mut self.cache, post.board.clone()) else { + return; + }; + + let Some((tcx, recv)) = self.recv_by_uuid.get(&msg.uuid) else { + return; + }; + + let id = post.id; + let tcx = &tcx; + let board = &board; + let post = &post; + + let html = PostTemplate { tcx, board, post } + .render() + .unwrap_or_default(); + + recv.do_send(SessionMessage::Data( + json!({ "type": "created", "id": id, "html": html }).to_string(), + )); + } +} + +impl Handler for LiveHub { + type Result = (); + + fn handle(&mut self, msg: PostUpdatedMessage, _: &mut Self::Context) -> Self::Result { + let post = msg.post; + + let uuids = self + .recv_by_thread + .get(&(post.board.clone(), post.thread.unwrap_or(post.id))); + + let uuids = match uuids { + Some(uuids) => uuids, + None => return, + }; + + let Ok(Some(board)) = Board::read_sync(&mut self.cache, post.board.clone()) else { + return; + }; + + for uuid in uuids { + let Some((tcx, recv)) = self.recv_by_uuid.get_mut(uuid) else { + continue; + }; + + tcx.update_yous(&mut self.cache).ok(); + + let id = post.id; + let tcx = &tcx; + let board = &board; + let post = &post; + + let html = PostTemplate { tcx, post, board } + .render() + .unwrap_or_default(); + + recv.do_send(SessionMessage::Data( + json!({ "type": "updated", "id": id, "html": html }).to_string(), + )); + } + } +} + +impl Handler for LiveHub { + type Result = (); + + fn handle(&mut self, msg: PostRemovedMessage, _: &mut Self::Context) -> Self::Result { + let post = msg.post; + + let uuids = self + .recv_by_thread + .get(&(post.board.clone(), post.thread.unwrap_or(post.id))); + + let uuids = match uuids { + Some(uuids) => uuids, + None => return, + }; + + if post.thread.is_none() { + for uuid in uuids { + let Some((_, recv)) = self.recv_by_uuid.get(uuid) else { + continue; + }; + + recv.do_send(SessionMessage::Stop); + } + + return; + } + + for uuid in uuids { + let Some((_, recv)) = self.recv_by_uuid.get(uuid) else { + continue; + }; + + recv.do_send(SessionMessage::Data( + json!({ "type": "removed", "id": post.id }).to_string(), + )); + } + } +} diff --git a/src/live_session.rs b/src/live_session.rs new file mode 100644 index 0000000..c4d79ee --- /dev/null +++ b/src/live_session.rs @@ -0,0 +1,73 @@ +use actix::{Actor, ActorContext, Addr, AsyncContext, Handler, StreamHandler}; +use actix_web_actors::ws::{Message as WsMessage, ProtocolError, WebsocketContext}; +use serde_json::json; +use uuid::Uuid; + +use crate::{ + live_hub::{ConnectMessage, DisconnectMessage, LiveHub, SessionMessage}, + web::tcx::TemplateCtx, +}; + +pub struct LiveSession { + pub uuid: Uuid, + pub thread: (String, i64), + pub tcx: TemplateCtx, + pub hub: Addr, +} + +impl Actor for LiveSession { + type Context = WebsocketContext; +} + +impl Handler for LiveSession { + type Result = (); + + fn handle(&mut self, msg: SessionMessage, ctx: &mut Self::Context) -> Self::Result { + match msg { + SessionMessage::Data(data) => ctx.text(data), + SessionMessage::Stop => { + ctx.text(json!({ "type": "thread_removed" }).to_string()); + self.finished(ctx) + } + }; + } +} + +impl StreamHandler> for LiveSession { + fn started(&mut self, ctx: &mut Self::Context) { + let uuid = self.uuid; + let thread = self.thread.clone(); + let tcx = self.tcx.clone(); + let recv = ctx.address().recipient(); + + self.hub.do_send(ConnectMessage { + uuid, + thread, + tcx, + recv, + }); + } + + fn handle(&mut self, msg: Result, ctx: &mut Self::Context) { + match msg { + Ok(WsMessage::Text(text)) => { + if text == "{\"type\":\"ping\"}" { + ctx.text("{\"type\":\"pong\"}"); + } + } + Ok(WsMessage::Ping(data)) => ctx.pong(&data), + Ok(WsMessage::Close(_)) => self.finished(ctx), + _ => (), + } + } + + fn finished(&mut self, ctx: &mut Self::Context) { + self.hub.do_send(DisconnectMessage { + uuid: self.uuid, + thread: self.thread.clone(), + }); + + ctx.close(None); + ctx.stop(); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100755 index 0000000..0d03e90 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,188 @@ +use actix_files::{Files, NamedFile}; +use actix_web::{ + body::MessageBody, + dev::ServiceResponse, + get, + http::header::{HeaderValue, CACHE_CONTROL, PRAGMA}, + middleware::{ErrorHandlerResponse, ErrorHandlers}, + web::Data, + App, HttpRequest, HttpResponse, HttpServer, ResponseError, +}; +use anyhow::Error; +use askama::Template; +use log::{error, info}; +use nekrochan::{ + cfg::Cfg, + ctx::Ctx, + db::{cache::init_cache, models::Banner}, + error::NekrochanError, + schedule::s_cleanup_files, + web::{self, template_response}, +}; +use sqlx::migrate; +use std::{env::var, time::Duration}; +use tokio::time::sleep; + +#[actix_web::main] +async fn main() { + dotenv::dotenv().ok(); + env_logger::init(); + + if let Err(err) = run().await { + error!("{err:?}"); + } +} + +async fn run() -> Result<(), Error> { + let cfg_path = var("NEKROCHAN_CONFIG").unwrap_or_else(|_| "Nekrochan.toml".into()); + + let cfg = Cfg::load(&cfg_path).await?; + let ctx = Ctx::new(cfg).await?; + + migrate!().run(ctx.db()).await?; + init_cache(&ctx).await?; + + let ctx_ = ctx.clone(); + + tokio::spawn(async move { + loop { + match s_cleanup_files(&ctx_).await { + Ok(()) => info!("Routine file cleanup successful."), + Err(err) => error!("Routine file cleanup failed: {err:?}"), + }; + + sleep(Duration::from_secs(ctx_.cfg.files.cleanup_interval)).await; + } + }); + + let bind_addr = ctx.bind_addr(); + + HttpServer::new(move || { + App::new() + .app_data(Data::new(ctx.clone())) + .service(web::board::board) + .service(web::board_catalog::board_catalog) + .service(web::index::index) + .service(web::captcha::captcha) + .service(web::edit_posts::edit_posts) + .service(web::ip_posts::ip_posts) + .service(web::live::live) + .service(web::login::login_get) + .service(web::login::login_post) + .service(web::logout::logout) + .service(web::news::news) + .service(web::overboard::overboard) + .service(web::overboard_catalog::overboard_catalog) + .service(web::page::page) + .service(web::search::search) + .service(web::thread::thread) + .service(web::thread_json::thread_json) + .service(web::actions::appeal_ban::appeal_ban) + .service(web::actions::create_post::create_post) + .service(web::actions::edit_posts::edit_posts) + .service(web::actions::report_posts::report_posts) + .service(web::actions::staff_post_actions::staff_post_actions) + .service(web::actions::user_post_actions::user_post_actions) + .service(web::staff::account::account) + .service(web::staff::accounts::accounts) + .service(web::staff::bans::bans) + .service(web::staff::banners::banners) + .service(web::staff::board_config::board_config) + .service(web::staff::boards::boards) + .service(web::staff::edit_news::edit_news) + .service(web::staff::news::news) + .service(web::staff::permissions::permissions) + .service(web::staff::reports::reports) + .service(web::staff::actions::add_banners::add_banners) + .service(web::staff::actions::change_password::change_password) + .service(web::staff::actions::create_account::create_account) + .service(web::staff::actions::create_board::create_board) + .service(web::staff::actions::create_news::create_news) + .service(web::staff::actions::delete_account::delete_account) + .service(web::staff::actions::edit_news::edit_news) + .service(web::staff::actions::remove_accounts::remove_accounts) + .service(web::staff::actions::remove_banners::remove_banners) + .service(web::staff::actions::remove_bans::remove_bans) + .service(web::staff::actions::remove_boards::remove_boards) + .service(web::staff::actions::remove_news::remove_news) + .service(web::staff::actions::transfer_ownership::transfer_ownership) + .service(web::staff::actions::update_board_config::update_board_config) + .service(web::staff::actions::update_boards::update_boards) + .service(web::staff::actions::update_permissions::update_permissions) + .service(favicon) + .service(random_banner) + .service(Files::new("/static", "./static")) + .service(Files::new("/uploads", "./uploads").disable_content_disposition()) + .wrap(ErrorHandlers::new().default_handler(error_handler)) + }) + .bind(bind_addr)? + .run() + .await?; + + Ok(()) +} + +#[get("/favicon.ico")] +async fn favicon() -> Result { + let favicon = NamedFile::open("./static/favicon.ico")?; + + Ok(favicon) +} + +#[get("/random-banner")] +async fn random_banner(ctx: Data, req: HttpRequest) -> Result { + let file = if let Some(banner) = Banner::read_random(&ctx).await? { + let timestamp = banner.banner.timestamp; + let format = &banner.banner.format; + + NamedFile::open(format!("./uploads/{timestamp}.{format}"))? + } else { + NamedFile::open("./static/default-banner.png")? + }; + + let mut res = file.into_response(&req); + + res.headers_mut().append( + CACHE_CONTROL, + HeaderValue::from_static("no-cache, no-store, must-revalidate"), + ); + + res.headers_mut() + .append(PRAGMA, HeaderValue::from_static("no-cache")); + + Ok(res) +} + +#[derive(Template)] +#[template(path = "error.html")] +struct ErrorTempalate { + error_code: u16, + error_message: String, +} + +fn error_handler(res: ServiceResponse) -> actix_web::Result> +where + B: MessageBody, + ::Error: ResponseError + 'static, +{ + let (req, res) = res.into_parts(); + let status = res.status(); + + let error_code = status.as_u16(); + let error_message = match res.into_body().try_into_bytes().ok() { + Some(bytes) => String::from_utf8(bytes.to_vec()).unwrap_or_default(), + None => String::default(), + }; + + let template = ErrorTempalate { + error_code, + error_message, + }; + + let mut res = template_response(&template)?; + *(res.status_mut()) = status; + + let res = ServiceResponse::new(req, res).map_into_right_body(); + + Ok(ErrorHandlerResponse::Response(res)) +} diff --git a/src/markup.rs b/src/markup.rs new file mode 100644 index 0000000..79df423 --- /dev/null +++ b/src/markup.rs @@ -0,0 +1,229 @@ +use lazy_static::lazy_static; +use regex::{Captures, Regex}; +use sqlx::query_as; +use std::collections::HashMap; + +use crate::{ + ctx::Ctx, + db::models::Post, + error::NekrochanError, + perms::PermissionWrapper, + trip::{secure_tripcode, tripcode}, +}; + +lazy_static! { + pub static ref NAME_REGEX: Regex = + Regex::new(r"^([^#].*?)?(?:(##([^ ].*?)|#([^#].*?)))?(##( .*?)?)?$").unwrap(); + pub static ref QUOTE_REGEX: Regex = Regex::new(r">>(\d+)").unwrap(); + pub static ref GREENTEXT_REGEX: Regex = Regex::new(r"(?mR)^>(.*)$").unwrap(); + pub static ref ORANGETEXT_REGEX: Regex = Regex::new(r"(?mR)^<(.*)$").unwrap(); + pub static ref REDTEXT_REGEX: Regex = Regex::new(r"==(.+?)==").unwrap(); + pub static ref BLUETEXT_REGEX: Regex = Regex::new(r"--(.+?)--").unwrap(); + pub static ref GLOWTEXT_REGEX: Regex = Regex::new(r"\%\%(.+?)\%\%").unwrap(); + pub static ref UH_OH_TEXT_REGEX: Regex = Regex::new(r"\(\(\((.+?)\)\)\)").unwrap(); + pub static ref SPOILER_REGEX: Regex = Regex::new(r"\|\|([\s\S]+?)\|\|").unwrap(); + pub static ref URL_REGEX: Regex = + Regex::new(r"https?\://[^\s<>\[\]{}|\\^]+").unwrap(); + pub static ref JANNYTEXT_REGEX: Regex = Regex::new(r"##(.+?)##").unwrap(); +} + +pub fn parse_name( + ctx: &Ctx, + perms: &PermissionWrapper, + anon_name: &str, + name: &str, +) -> Result<(String, Option, Option), NekrochanError> { + let Some(captures) = NAME_REGEX.captures(name) else { + return Ok((anon_name.to_owned(), None, None)); + }; + + let name = match captures.get(1) { + Some(name) => { + let name = name.as_str().to_owned(); + + if name.len() > 32 { + return Err(NekrochanError::PostNameFormatError); + } + + name + } + None => anon_name.to_owned(), + }; + + let tripcode = match captures.get(2) { + Some(_) => { + let strip = captures.get(3); + let itrip = captures.get(4); + + if let Some(strip) = strip { + let trip = secure_tripcode(strip.as_str(), &ctx.cfg.secrets.secure_trip); + + Some(format!("!!{trip}")) + } else if let Some(itrip) = itrip { + let trip = tripcode(itrip.as_str()); + + Some(format!("!{trip}")) + } else { + None + } + } + None => None, + }; + + if !(perms.owner() || perms.capcodes()) { + return Ok((name, tripcode, None)); + } + + let capcode = match captures.get(5) { + Some(_) => match captures.get(6) { + Some(capcode) => { + let capcode: String = capcode.as_str().trim().into(); + + if capcode.is_empty() || !(perms.owner() || perms.custom_capcodes()) { + Some(capcode_fallback(perms.owner())) + } else { + if capcode.len() > 32 { + return Err(NekrochanError::CapcodeFormatError); + } + + Some(capcode) + } + } + None => Some(capcode_fallback(perms.owner())), + }, + None => None, + }; + + Ok((name, tripcode, capcode)) +} + +fn capcode_fallback(owner: bool) -> String { + if owner { + "Admin".into() + } else { + "Uklízeč".into() + } +} + +pub async fn markup( + ctx: &Ctx, + perms: &PermissionWrapper, + board: Option, + op: Option, + text: &str, +) -> Result<(String, Vec), NekrochanError> { + let text = escape_html(text); + + let (text, quoted_posts) = if let Some(board) = board { + let quoted_posts = get_quoted_posts(ctx, &board, &text).await?; + + let text = QUOTE_REGEX.replace_all(&text, |captures: &Captures| { + let id_raw = &captures[1]; + + let Ok(id) = id_raw.parse() else { + return format!(">>{id_raw}"); + }; + + let post = quoted_posts.get(&id); + + if let Some(post) = post { + format!( + ">>{}{}", + post.post_url(), + post.id, + if op == Some(post.id) { + " (OP)" + } else { + "" + } + ) + } else { + format!(">>{id}") + } + }); + + let quoted_posts = quoted_posts + .into_values() + .filter(|post| op == Some(post.thread.unwrap_or(post.id))) + .collect(); + + (text.to_string(), quoted_posts) + } else { + (text, Vec::new()) + }; + + let text = GREENTEXT_REGEX.replace_all(&text, ">$1"); + let text = ORANGETEXT_REGEX.replace_all(&text, "<$1"); + let text = REDTEXT_REGEX.replace_all(&text, "$1"); + let text = BLUETEXT_REGEX.replace_all(&text, "$1"); + let text = GLOWTEXT_REGEX.replace_all(&text, "$1"); + let text = SPOILER_REGEX.replace_all(&text, "$1"); + + let text = UH_OH_TEXT_REGEX.replace_all(&text, |captures: &Captures| { + format!( + "((( {} )))", + captures[1].trim() + ) + }); + + let text = URL_REGEX.replace_all(&text, |captures: &Captures| { + let url = &captures[0]; + + format!("{url}") + }); + + let text = if perms.owner() || perms.jannytext() { + JANNYTEXT_REGEX.replace_all(&text, "$1") + } else { + text + }; + + Ok((text.to_string(), quoted_posts)) +} + +fn escape_html(text: &str) -> String { + text.replace('&', "&") + .replace('\'', "'") + .replace('/', "/") + .replace('`', "`") + .replace('=', "=") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +async fn get_quoted_posts( + ctx: &Ctx, + board: &String, + text: &str, +) -> Result, NekrochanError> { + let mut quoted_ids: Vec = Vec::new(); + + for quote in QUOTE_REGEX.captures_iter(text) { + let id_raw = "e[1]; + let Ok(id) = id_raw.parse() else { continue }; + + quoted_ids.push(id); + } + + if quoted_ids.is_empty() { + return Ok(HashMap::new()); + } + + let in_list = quoted_ids + .iter() + .map(std::string::ToString::to_string) + .collect::>() + .join(","); + + let quoted_posts = query_as(&format!( + "SELECT * FROM posts_{board} WHERE id IN ({in_list})" + )) + .fetch_all(ctx.db()) + .await? + .into_iter() + .map(|post: Post| (post.id, post)) + .collect::>(); + + Ok(quoted_posts) +} diff --git a/src/perms.rs b/src/perms.rs new file mode 100755 index 0000000..c10e8ec --- /dev/null +++ b/src/perms.rs @@ -0,0 +1,111 @@ +use enumflags2::{bitflags, BitFlags}; + +#[bitflags] +#[repr(u64)] +#[derive(Copy, Clone, Debug, PartialEq)] +pub enum Permissions { + EditPosts, + ManagePosts, + Capcodes, + CustomCapcodes, + StaffLog, + Reports, + Bans, + BoardBanners, + BoardConfig, + News, + Jannytext, + ViewIPs, + BypassBans, + BypassBoardLock, + BypassThreadLock, + BypassCaptcha, + BypassAntispam, +} + +#[derive(Debug, Clone)] +pub struct PermissionWrapper(BitFlags, bool); + +impl PermissionWrapper { + pub fn new(perms: u64, owner: bool) -> Self { + Self(BitFlags::from_bits_truncate(perms), owner) + } +} + +impl PermissionWrapper { + pub fn integer(&self) -> u64 { + self.0.bits() + } + + pub fn owner(&self) -> bool { + self.1 + } + + pub fn edit_posts(&self) -> bool { + self.0.contains(Permissions::EditPosts) + } + + pub fn manage_posts(&self) -> bool { + self.0.contains(Permissions::ManagePosts) + } + + pub fn capcodes(&self) -> bool { + self.0.contains(Permissions::Capcodes) + } + + pub fn custom_capcodes(&self) -> bool { + self.0.contains(Permissions::CustomCapcodes) + } + + pub fn staff_log(&self) -> bool { + self.0.contains(Permissions::StaffLog) + } + + pub fn reports(&self) -> bool { + self.0.contains(Permissions::Reports) + } + + pub fn bans(&self) -> bool { + self.0.contains(Permissions::Bans) + } + + pub fn banners(&self) -> bool { + self.0.contains(Permissions::BoardBanners) + } + + pub fn board_config(&self) -> bool { + self.0.contains(Permissions::BoardConfig) + } + + pub fn news(&self) -> bool { + self.0.contains(Permissions::News) + } + + pub fn jannytext(&self) -> bool { + self.0.contains(Permissions::Jannytext) + } + + pub fn view_ips(&self) -> bool { + self.0.contains(Permissions::ViewIPs) + } + + pub fn bypass_bans(&self) -> bool { + self.0.contains(Permissions::BypassBans) + } + + pub fn bypass_board_lock(&self) -> bool { + self.0.contains(Permissions::BypassBoardLock) + } + + pub fn bypass_thread_lock(&self) -> bool { + self.0.contains(Permissions::BypassThreadLock) + } + + pub fn bypass_captcha(&self) -> bool { + self.0.contains(Permissions::BypassCaptcha) + } + + pub fn bypass_antispam(&self) -> bool { + self.0.contains(Permissions::BypassAntispam) + } +} diff --git a/src/qsform.rs b/src/qsform.rs new file mode 100644 index 0000000..ccfaa53 --- /dev/null +++ b/src/qsform.rs @@ -0,0 +1,43 @@ +use actix_web::{dev::Payload, http::StatusCode, FromRequest, HttpRequest, ResponseError}; +use serde::Deserialize; +use serde_qs::Config; +use std::{future::Future, pin::Pin}; +use thiserror::Error; + +pub struct QsForm(pub T) +where + T: for<'de> Deserialize<'de>; + +#[derive(Debug, Error)] +pub enum QsFormError { + #[error("{}", .0)] + FutureError(#[from] actix_web::Error), + #[error("{}", .0)] + ParseError(#[from] serde_qs::Error), +} + +impl ResponseError for QsFormError { + fn status_code(&self) -> StatusCode { + StatusCode::BAD_REQUEST + } +} + +impl FromRequest for QsForm +where + T: for<'de> Deserialize<'de>, +{ + type Error = QsFormError; + type Future = Pin, Self::Error>>>>; + + fn from_request(req: &HttpRequest, payload: &mut Payload) -> Self::Future { + let data = String::from_request(req, payload); + + Box::pin(async move { + let data = data.await?; + let config = Config::new(10, false); + let form: T = config.deserialize_str(&data)?; + + Ok(QsForm(form)) + }) + } +} diff --git a/src/schedule.rs b/src/schedule.rs new file mode 100644 index 0000000..ba94978 --- /dev/null +++ b/src/schedule.rs @@ -0,0 +1,67 @@ +use anyhow::Error; +use glob::glob; +use std::collections::HashSet; +use tokio::fs::remove_file; + +use crate::{ + ctx::Ctx, + db::models::{Banner, Board, Post}, +}; + +pub async fn s_cleanup_files(ctx: &Ctx) -> Result<(), Error> { + let mut keep = HashSet::new(); + let mut keep_thumbs = HashSet::new(); + + let banners = Banner::read_all(ctx).await?; + + for banner in banners { + keep.insert(format!( + "{}.{}", + banner.banner.timestamp, banner.banner.format + )); + } + + let boards = Board::read_all(ctx).await?; + + for board in boards { + let posts = Post::read_all(ctx, board.id.clone()).await?; + + for post in posts { + for file in post.files.0 { + keep.insert(format!("{}.{}", file.timestamp, file.format)); + + if let Some(thumb_format) = file.thumb_format { + keep_thumbs.insert(format!("{}.{}", file.timestamp, thumb_format)); + } + } + } + } + + for file in glob("./uploads/*.*")? { + let file = file?; + let file_name = file.file_name(); + + if let Some(file_name) = file_name { + let check = file_name.to_string_lossy().to_string(); + + if !keep.contains(&check) { + remove_file(file).await?; + } + } + } + + for file in glob("./uploads/thumb/*.*")? { + let file = file?; + let file_name = file.file_name(); + + if let Some(file_name) = file_name { + let check = file_name.to_string_lossy().to_string(); + + if !keep_thumbs.contains(&check) { + remove_file(file).await?; + } + } + } + + Ok(()) +} diff --git a/src/trip.rs b/src/trip.rs new file mode 100755 index 0000000..0818739 --- /dev/null +++ b/src/trip.rs @@ -0,0 +1,54 @@ +use encoding::{all::WINDOWS_31J, EncoderTrap, Encoding}; +use pwhash::{ + bcrypt::{self, BcryptSetup}, + unix::crypt, +}; + +pub fn tripcode(password: &str) -> String { + let password = WINDOWS_31J.encode(password, EncoderTrap::Replace).unwrap(); + + let salt = [password.as_ref(), "H.".as_bytes()].concat(); + let salt = &salt[1..3]; + let salt = salt + .iter() + .map(|c| match c { + 46..=122 => *c, + _ => 46, + } as char) + .map(|c| match c { + ':' => 'A', + ';' => 'B', + '<' => 'C', + '=' => 'D', + '>' => 'E', + '?' => 'F', + '@' => 'G', + '[' => 'a', + '\\' => 'b', + ']' => 'c', + '^' => 'd', + '_' => 'e', + '`' => 'f', + _ => c, + }) + .collect::(); + + let trip = crypt(password, &salt).unwrap(); + + trip[3..].to_owned() +} + +pub fn secure_tripcode(password: &str, tripcode_secret: &str) -> String { + let trip = bcrypt::hash_with( + BcryptSetup { + salt: Some(tripcode_secret), + ..Default::default() + }, + password, + ) + .unwrap(); + + let trip = &trip[trip.len() - 10..]; + + trip.into() +} diff --git a/src/web/actions/appeal_ban.rs b/src/web/actions/appeal_ban.rs new file mode 100644 index 0000000..e6e02f9 --- /dev/null +++ b/src/web/actions/appeal_ban.rs @@ -0,0 +1,59 @@ +use actix_web::{post, web::Data, HttpRequest, HttpResponse}; +use serde::Deserialize; + +use super::ActionTemplate; +use crate::{ + ctx::Ctx, + db::models::Ban, + error::NekrochanError, + qsform::QsForm, + web::{ + tcx::{ip_from_req, TemplateCtx}, + template_response, + }, +}; + +#[derive(Deserialize)] +pub struct AppealBanForm { + pub id: i32, + pub appeal: String, +} + +#[post("/actions/appeal-ban")] +pub async fn appeal_ban( + ctx: Data, + req: HttpRequest, + QsForm(form): QsForm, +) -> Result { + let tcx = TemplateCtx::new(&ctx, &req).await?; + let (ip, _) = ip_from_req(&req)?; + + let ban = Ban::read_by_id(&ctx, form.id) + .await? + .ok_or(NekrochanError::BanNotFound)?; + + if !ban.ip_range.contains(ip) { + return Err(NekrochanError::BanNotFound); + } + + if ban.appeal.is_some() { + return Err(NekrochanError::AlreadyAppealedError); + } + + if !ban.appealable { + return Err(NekrochanError::UnappealableError); + } + + let appeal: String = form.appeal.trim().into(); + + if appeal.is_empty() || appeal.len() > 1000 { + return Err(NekrochanError::BanAppealFormatError); + } + + ban.update_appeal(&ctx, appeal).await?; + + template_response(&ActionTemplate { + tcx, + response: "Ban byl úspěšně odvolán.".into(), + }) +} diff --git a/src/web/actions/create_post.rs b/src/web/actions/create_post.rs new file mode 100644 index 0000000..b138249 --- /dev/null +++ b/src/web/actions/create_post.rs @@ -0,0 +1,341 @@ +use actix_multipart::form::{tempfile::TempFile, text::Text, MultipartForm}; +use actix_web::{ + cookie::Cookie, http::StatusCode, post, web::Data, HttpRequest, HttpResponse, + HttpResponseBuilder, +}; +use chrono::{Duration, Utc}; +use pwhash::bcrypt::hash; +use redis::AsyncCommands; +use sha256::digest; +use std::{collections::HashSet, net::IpAddr}; + +use crate::{ + ctx::Ctx, + db::models::{Ban, Board, File, Post}, + error::NekrochanError, + markup::{markup, parse_name}, + perms::PermissionWrapper, + web::{ + ban_response, + tcx::{account_from_auth_opt, ip_from_req}, + }, +}; + +#[derive(MultipartForm)] +pub struct PostForm { + pub board: Text, + pub thread: Option>, + #[multipart(rename = "post_name")] + pub name: Text, + pub email: Text, + pub content: Text, + #[multipart(rename = "files[]")] + pub files: Vec, + pub spoiler_files: Option>, + #[multipart(rename = "post_password")] + pub password: Text, + pub captcha_id: Option>, + pub captcha_solution: Option>, +} + +#[post("/actions/create-post")] +pub async fn create_post( + ctx: Data, + req: HttpRequest, + MultipartForm(form): MultipartForm, +) -> Result { + let perms = match account_from_auth_opt(&ctx, &req).await? { + Some(account) => account.perms(), + None => PermissionWrapper::new(0, false), + }; + + let (ip, country) = ip_from_req(&req)?; + + let board = form.board.0; + let board = Board::read(&ctx, board.clone()) + .await? + .ok_or(NekrochanError::BoardNotFound(board))?; + + if let Some(ban) = Ban::read(&ctx, board.id.clone(), ip).await? { + if !(perms.owner() || perms.bypass_bans()) { + return ban_response(&ctx, &req, ban).await; + } + } + + if board.config.0.locked && !(perms.owner() || perms.bypass_board_lock()) { + return Err(NekrochanError::BoardLockError(board.id.clone())); + } + + let mut bump = true; + let mut noko = ctx.cfg.site.noko; + + let thread = match form.thread { + Some(Text(thread)) => { + let thread = Post::read(&ctx, board.id.clone(), thread) + .await? + .ok_or(NekrochanError::PostNotFound(board.id.clone(), thread))?; + + if thread.thread.is_some() { + return Err(NekrochanError::IsReplyError); + } + + if thread.locked && !(perms.owner() || perms.bypass_thread_lock()) { + return Err(NekrochanError::ThreadLockError); + } + + if thread.replies >= board.config.0.reply_limit { + return Err(NekrochanError::ReplyLimitError); + } + + if thread.bumps >= board.config.0.bump_limit { + bump = false; + } + + Some(thread) + } + None => None, + }; + + if !(perms.owner() || perms.bypass_captcha()) + && ((thread.is_none() && board.config.0.thread_captcha != "off") + || (thread.is_some() && board.config.0.reply_captcha != "off")) + { + let board = board.id.clone(); + + let id = form + .captcha_id + .ok_or(NekrochanError::RequiredCaptchaError)? + .0; + + if id.is_empty() { + return Err(NekrochanError::RequiredCaptchaError); + } + + let key = format!("captcha:{board}:{id}"); + + let solution = form + .captcha_solution + .ok_or(NekrochanError::RequiredCaptchaError)?; + + let actual_solution: Option = ctx.cache().get_del(key).await?; + let actual_solution = actual_solution.ok_or(NekrochanError::InvalidCaptchaError)?; + + if solution.trim() != actual_solution { + return Err(NekrochanError::IncorrectCaptchaError); + } + } + + let name_raw = form.name.trim(); + let (name, tripcode, capcode) = parse_name(&ctx, &perms, &board.config.0.anon_name, name_raw)?; + + let email_raw = form.email.trim(); + + let email = if email_raw.is_empty() { + None + } else { + if email_raw.len() > 256 { + return Err(NekrochanError::EmailFormatError); + } + + let email_lower = email_raw.to_lowercase(); + + if email_lower == "sage" { + bump = false; + } + + if !ctx.cfg.site.noko && email_lower == "noko" { + noko = true + } + + if ctx.cfg.site.noko { + if email_lower == "nonoko" { + noko = false; + } + + if email_lower == "nonokosage" { + noko = false; + bump = false; + } + } else { + if email_lower == "noko" { + noko = true; + } + + if email_lower == "nokosage" { + noko = true; + bump = false; + } + } + + Some(email_raw.into()) + }; + + let password_raw = form.password.trim(); + + if password_raw.len() < 8 { + return Err(NekrochanError::PasswordFormatError); + } + + let password = hash(password_raw)?; + + if form.files.len() > board.config.0.file_limit { + return Err(NekrochanError::FileLimitError(board.config.0.file_limit)); + } + + let mut files = Vec::new(); + + for file in form.files { + if file.size == 0 { + continue; + } + + let spoiler = form.spoiler_files.is_some(); + let file = File::new(&ctx.cfg, file, spoiler, true).await?; + + files.push(file); + } + + let thread_id = thread.as_ref().map(|t| t.id); + let content_nomarkup = form.content.0.trim().to_owned(); + + if board.config.antispam && !(perms.owner() || perms.bypass_antispam()) { + check_spam(&ctx, &board, ip, content_nomarkup.clone()).await?; + } + + if content_nomarkup.is_empty() && files.is_empty() { + return Err(NekrochanError::EmptyPostError); + } + + if content_nomarkup.is_empty() && (thread.is_none() && board.config.0.require_thread_content) + || (thread.is_some() && board.config.0.require_reply_content) + { + return Err(NekrochanError::NoContentError); + } + + if content_nomarkup.len() > 10000 { + return Err(NekrochanError::ContentFormatError); + } + + let (content, quoted_posts) = markup( + &ctx, + &perms, + Some(board.id.clone()), + thread.as_ref().map(|t| t.id), + &content_nomarkup, + ) + .await?; + + let post = Post::create( + &ctx, + &board, + thread_id, + name, + tripcode, + capcode, + email, + content, + content_nomarkup, + files, + password, + country, + ip, + bump, + ) + .await?; + + for quoted_post in quoted_posts { + quoted_post.update_quotes(&ctx, post.id).await?; + } + + let ts = thread.as_ref().map_or_else( + || post.created.timestamp_micros(), + |thread| thread.created.timestamp_micros(), + ); + + let hash_input = format!("{}:{}:{}", ip, ts, ctx.cfg.secrets.user_id); + let user_hash = digest(hash_input); + let user_id = user_hash[..6].to_owned(); + + post.update_user_id(&ctx, user_id).await?; + + let mut res = HttpResponseBuilder::new(StatusCode::SEE_OTHER); + + let name_cookie = Cookie::build("name", name_raw).path("/").finish(); + let password_cookie = Cookie::build("password", password_raw).path("/").finish(); + let email_cookie = Cookie::build("email", email_raw).path("/").finish(); + + res.cookie(name_cookie); + res.cookie(password_cookie); + res.cookie(email_cookie); + + let res = if noko { + res.append_header(("Location", post.post_url().as_str())) + .finish() + } else { + res.append_header(("Location", format!("/boards/{}", post.board).as_str())) + .finish() + }; + + Ok(res) +} + +pub async fn check_spam( + ctx: &Ctx, + board: &Board, + ip: IpAddr, + content_nomarkup: String, +) -> Result<(), NekrochanError> { + let ip_key = format!("by_ip:{ip}"); + let content_key = format!("by_content:{}", digest(content_nomarkup)); + + let antispam_ip = (Utc::now() - Duration::seconds(board.config.antispam_ip)).timestamp_micros(); + let antispam_content = + (Utc::now() - Duration::seconds(board.config.antispam_content)).timestamp_micros(); + let antispam_both = + (Utc::now() - Duration::seconds(board.config.antispam_both)).timestamp_micros(); + + let ip_posts: HashSet = ctx + .cache() + .zrangebyscore(&ip_key, antispam_ip, "+inf") + .await?; + let content_posts: HashSet = ctx + .cache() + .zrangebyscore(&content_key, antispam_content, "+inf") + .await?; + + let ip_posts2: HashSet = ctx + .cache() + .zrangebyscore(&ip_key, antispam_both, "+inf") + .await?; + let content_posts2: HashSet = ctx + .cache() + .zrangebyscore(&content_key, antispam_both, "+inf") + .await?; + + let both_posts = ip_posts2.intersection(&content_posts2); + + if !ip_posts.is_empty() { + return Err(NekrochanError::FloodError); + } + + if !content_posts.is_empty() { + return Err(NekrochanError::FloodError); + } + + if both_posts.count() != 0 { + return Err(NekrochanError::FloodError); + } + + let last_thread: Option = ctx.cache().get(format!("last_thread:{ip}")).await?; + + if let Some(last_thread) = last_thread { + let since_last_thread = Utc::now().timestamp_micros() - last_thread; + let since_last_thread = Duration::microseconds(since_last_thread); + + if since_last_thread.num_seconds() < board.config.thread_cooldown { + return Err(NekrochanError::FloodError); + } + } + + Ok(()) +} diff --git a/src/web/actions/edit_posts.rs b/src/web/actions/edit_posts.rs new file mode 100644 index 0000000..22b0368 --- /dev/null +++ b/src/web/actions/edit_posts.rs @@ -0,0 +1,76 @@ +use actix_web::{post, web::Data, HttpRequest, HttpResponse}; +use sqlx::query; +use std::{collections::HashMap, fmt::Write}; + +use crate::{ + ctx::Ctx, + db::models::Post, + error::NekrochanError, + markup::markup, + qsform::QsForm, + web::{tcx::TemplateCtx, template_response}, +}; + +use super::{get_posts_from_ids, ActionTemplate}; + +#[post("/actions/edit-posts")] +pub async fn edit_posts( + ctx: Data, + req: HttpRequest, + QsForm(edits): QsForm>, +) -> Result { + let tcx = TemplateCtx::new(&ctx, &req).await?; + + if !(tcx.perms.owner() || tcx.perms.edit_posts()) { + return Err(NekrochanError::InsufficientPermissionError); + } + + let ids = edits.keys().map(|s| s.to_owned()).collect::>(); + + let posts = get_posts_from_ids(&ctx, &ids) + .await + .into_iter() + .map(|post| (format!("{}/{}", post.board, post.id), post)) + .collect::>(); + + let mut response = String::new(); + let mut posts_edited = 0; + + for (key, content_nomarkup) in edits { + let post = &posts[&key]; + let content_nomarkup = content_nomarkup.trim(); + let (content, quoted_posts) = markup( + &ctx, + &tcx.perms, + Some(post.board.clone()), + post.thread, + content_nomarkup, + ) + .await?; + + post.update_content(&ctx, content, content_nomarkup.into()) + .await?; + + query(&format!( + "UPDATE posts_{} SET quotes = array_remove(quotes, $1) WHERE $1 = ANY(quotes)", + post.board + )) + .bind(post.id) + .execute(ctx.db()) + .await?; + + for quoted_post in quoted_posts { + quoted_post.update_quotes(&ctx, post.id).await?; + } + + posts_edited += 1; + } + + if posts_edited != 0 { + writeln!(&mut response, "[Úspěch] Upraveny příspěvky: {posts_edited}").ok(); + } + + let template = ActionTemplate { tcx, response }; + + template_response(&template) +} diff --git a/src/web/actions/mod.rs b/src/web/actions/mod.rs new file mode 100644 index 0000000..8f9e0e8 --- /dev/null +++ b/src/web/actions/mod.rs @@ -0,0 +1,46 @@ +use askama::Template; +use sqlx::query_as; + +use super::tcx::TemplateCtx; +use crate::{ctx::Ctx, db::models::Post}; + +pub mod appeal_ban; +pub mod create_post; +pub mod edit_posts; +pub mod report_posts; +pub mod staff_post_actions; +pub mod user_post_actions; + +#[derive(Template)] +#[template(path = "action.html")] +pub struct ActionTemplate { + pub tcx: TemplateCtx, + pub response: String, +} + +pub async fn get_posts_from_ids(ctx: &Ctx, ids: &Vec) -> Vec { + let mut posts = Vec::new(); + + for id in ids { + if let Some((board, id)) = parse_id(id) { + if let Ok(Some(post)) = query_as("SELECT * FROM overboard WHERE board = $1 AND id = $2") + .bind(board) + .bind(id) + .fetch_optional(ctx.db()) + .await + { + posts.push(post); + } + } + } + + posts +} + +fn parse_id(id: &str) -> Option<(String, i64)> { + let (board, id) = id.split_once('/')?; + let board = board.to_owned(); + let id = id.parse().ok()?; + + Some((board, id)) +} diff --git a/src/web/actions/report_posts.rs b/src/web/actions/report_posts.rs new file mode 100644 index 0000000..b98347b --- /dev/null +++ b/src/web/actions/report_posts.rs @@ -0,0 +1,110 @@ +use actix_web::{post, web::Data, HttpRequest, HttpResponse}; +use serde::Deserialize; +use std::fmt::Write; + +use crate::{ + ctx::Ctx, + db::models::{Ban, Board}, + error::NekrochanError, + qsform::QsForm, + web::{ + actions::{get_posts_from_ids, ActionTemplate}, + ban_response, + tcx::{ip_from_req, TemplateCtx}, + template_response, + }, +}; + +#[derive(Deserialize)] +pub struct ReportPostsForm { + #[serde(default)] + pub posts: Vec, + pub report_reason: String, +} + +#[post("/actions/report-posts")] +pub async fn report_posts( + ctx: Data, + req: HttpRequest, + QsForm(form): QsForm, +) -> Result { + let tcx = TemplateCtx::new(&ctx, &req).await?; + let (reporter_ip, reporter_country) = ip_from_req(&req)?; + let bans = Ban::read_by_ip(&ctx, reporter_ip).await?; + + if let Some(ban) = bans.get(&None) { + if !(tcx.perms.owner() || tcx.perms.bypass_bans()) { + return ban_response(&ctx, &req, ban.clone()).await; + } + } + + let boards = Board::read_all_map(&ctx).await?; + let posts = get_posts_from_ids(&ctx, &form.posts).await; + + let mut response = String::new(); + let mut posts_reported = 0; + + let reason = form.report_reason.trim(); + + if reason.is_empty() || reason.len() > 200 { + return Err(NekrochanError::ReportFormatError); + } + + for post in &posts { + let board = &boards[&post.board]; + + if bans.contains_key(&Some(board.id.clone())) + && !(tcx.perms.owner() || tcx.perms.bypass_bans()) + { + writeln!(&mut response, "[Chyba] Jsi zabanován z /{}/.", board.id).ok(); + continue; + } + + if board.config.0.locked && !(tcx.perms.owner() || tcx.perms.bypass_board_lock()) { + writeln!( + &mut response, + "[Chyba] {}", + NekrochanError::BoardLockError(board.id.clone()) + ) + .ok(); + + continue; + } + + if post + .reports + .iter() + .any(|report| report.reporter_ip == reporter_ip) + { + writeln!( + &mut response, + "[Chyba] Příspěvek #{} jsi už nahlásil.", + post.id + ) + .ok(); + continue; + } + + post.create_report( + &ctx, + reason.to_owned(), + reporter_country.clone(), + reporter_ip, + ) + .await?; + + posts_reported += 1; + } + + if posts_reported != 0 { + writeln!( + &mut response, + "[Úspěch] Nahlášeny příspěvky: {posts_reported}" + ) + .ok(); + } + + let template = ActionTemplate { tcx, response }; + + template_response(&template) +} diff --git a/src/web/actions/staff_post_actions.rs b/src/web/actions/staff_post_actions.rs new file mode 100644 index 0000000..423684f --- /dev/null +++ b/src/web/actions/staff_post_actions.rs @@ -0,0 +1,369 @@ +use actix_web::{post, web::Data, HttpRequest, HttpResponse}; +use chrono::{Duration, Utc}; +use ipnetwork::IpNetwork; +use redis::AsyncCommands; +use serde::Deserialize; +use std::{collections::HashSet, fmt::Write, net::IpAddr}; + +use crate::{ + ctx::Ctx, + db::models::{Account, Ban}, + error::NekrochanError, + qsform::QsForm, + web::{ + actions::{get_posts_from_ids, ActionTemplate}, + tcx::{account_from_auth, TemplateCtx}, + template_response, + }, +}; + +#[derive(Deserialize)] +pub struct StaffPostActionsForm { + #[serde(default)] + pub posts: Vec, + #[serde(rename = "staff_remove_posts")] + pub remove_posts: Option, + #[serde(rename = "staff_remove_files")] + pub remove_files: Option, + #[serde(rename = "staff_toggle_spoiler")] + pub toggle_spoiler: Option, + pub remove_by_ip_board: Option, + pub remove_by_ip_global: Option, + pub toggle_sticky: Option, + pub toggle_lock: Option, + pub remove_reports: Option, + pub ban_user: Option, + pub ban_reporters: Option, + pub global_ban: Option, + pub unappealable_ban: Option, + pub ban_reason: Option, + pub ban_duration: Option, + pub ban_range: Option, + pub troll_user: Option, +} + +#[post("/actions/staff-post-actions")] +pub async fn staff_post_actions( + ctx: Data, + req: HttpRequest, + QsForm(form): QsForm, +) -> Result { + let tcx = TemplateCtx::new(&ctx, &req).await?; + let account = account_from_auth(&ctx, &req).await?; + let posts = get_posts_from_ids(&ctx, &form.posts).await; + + let mut response = String::new(); + + let mut posts_removed = 0; + let mut files_removed = 0; + let mut spoilers_toggled = 0; + let mut stickies_toggled = 0; + let mut locks_toggled = 0; + let mut reports_removed = 0; + let mut bans_issued = 0; + let mut users_trolled = 0; + + for post in &posts { + if (form.remove_posts.is_some() + || form.remove_files.is_some() + || form.toggle_spoiler.is_some() + || form.toggle_sticky.is_some() + || form.toggle_lock.is_some()) + && !(account.perms().owner() || account.perms().manage_posts()) + { + writeln!( + &mut response, + "[Chyba] Nemáš oprávnění spravovat příspěvky." + ) + .ok(); + + continue; + } + + if form.remove_posts.is_some() { + post.delete(&ctx).await?; + posts_removed += 1; + } + + if form.remove_files.is_some() { + post.delete_files(&ctx).await?; + files_removed += post.files.0.len(); + } + + if form.toggle_spoiler.is_some() { + post.update_spoiler(&ctx).await?; + spoilers_toggled += post.files.0.len(); + } + + if form.remove_by_ip_board.is_some() { + let key = format!("by_ip:{}", post.ip); + let ip_posts: Vec = ctx.cache().zrange(key, 0, -1).await?; + let board_ip_posts = ip_posts + .into_iter() + .filter(|p| p.starts_with(&format!("{}/", post.board))) + .collect::>(); + + for post in get_posts_from_ids(&ctx, &board_ip_posts).await { + post.delete(&ctx).await?; + posts_removed += 1; + } + } + + if form.remove_by_ip_global.is_some() { + let key = format!("by_ip:{}", post.ip); + let ip_posts: Vec = ctx.cache().zrange(key, 0, -1).await?; + + for post in get_posts_from_ids(&ctx, &ip_posts).await { + post.delete(&ctx).await?; + posts_removed += 1; + } + } + + if form.toggle_sticky.is_some() { + post.update_sticky(&ctx).await?; + stickies_toggled += 1; + } + + if form.toggle_lock.is_some() { + if post.thread.is_some() { + writeln!(&mut response, "[Chyba] Odpověď nelze uzamknout.").ok(); + } else { + post.update_lock(&ctx).await?; + locks_toggled += 1; + } + } + } + + for post in &posts { + if form.remove_reports.is_some() { + if !(tcx.perms.owner() || tcx.perms.reports()) { + writeln!(&mut response, "[Chyba] Nemáš přístup k hlášením.").ok(); + continue; + } + + post.delete_reports(&ctx).await?; + reports_removed += post.reports.0.len(); + } + } + + let mut already_banned = HashSet::new(); + + for post in &posts { + if let ((Some(_), _) | (_, Some(_)), reason, duration, Some(range)) = ( + (form.ban_user.clone(), form.ban_reporters.clone()), + form.ban_reason.clone().unwrap_or_default(), + form.ban_duration.unwrap_or_default(), + form.ban_range.clone(), + ) { + if !(account.perms().owner() || account.perms().bans()) { + writeln!(&mut response, "[Chyba] Nemáš oprávnění vydat ban.").ok(); + continue; + } + + let mut ips_to_ban = HashSet::new(); + + if form.ban_user.is_some() && !already_banned.contains(&post.ip) { + ips_to_ban.insert(post.ip); + } + + if form.ban_reporters.is_some() { + if !(tcx.perms.owner() || tcx.perms.reports()) { + writeln!(&mut response, "[Chyba] Nemáš přístup k hlášením.").ok(); + continue; + } + + ips_to_ban.extend( + post.reports + .0 + .iter() + .map(|r| r.reporter_ip) + .filter(|ip| !already_banned.contains(ip)), + ) + } + + if ips_to_ban.is_empty() { + continue; + } + + for ip in &ips_to_ban { + ban_ip( + &ctx, + &account, + &form, + *ip, + post.board.clone(), + reason.clone(), + duration, + &range, + ) + .await?; + } + + if form.ban_user.is_some() { + let content_nomarkup = format!( + "{}\n\n##(UŽIVATEL BYL ZA TENTO PŘÍSPĚVEK ZABANOVÁN)##", + post.content_nomarkup + ); + + let content = format!( + "{}\n\n(UŽIVATEL BYL ZA TENTO PŘÍSPĚVEK ZABANOVÁN)", + post.content + ); + + post.update_content(&ctx, content, content_nomarkup).await?; + } + + bans_issued += ips_to_ban.len(); + already_banned.extend(ips_to_ban); + } + } + + for post in &posts { + if form.troll_user.is_some() { + if !(tcx.perms.owner() || tcx.perms.edit_posts()) { + writeln!( + &mut response, + "[Chyba] Nemáš oprávnění upravovat příspěvky." + ) + .ok(); + continue; + } + + if !(tcx.perms.owner() || tcx.perms.view_ips()) { + writeln!( + &mut response, + "[Chyba] Nemáš oprávnění zobrazovat IP adresy." + ) + .ok(); + continue; + } + + let content_nomarkup = format!("{}\n\n##({})##", post.content_nomarkup, post.ip); + + let content = format!( + "{}\n\n({})", + post.content, post.ip + ); + + post.update_content(&ctx, content, content_nomarkup).await?; + users_trolled += 1; + } + } + + if posts_removed != 0 { + writeln!( + &mut response, + "[Úspěch] Odstraněny příspěvky: {posts_removed}" + ) + .ok(); + } + + if files_removed != 0 { + writeln!( + &mut response, + "[Úspěch] Odstraněny soubory: {files_removed}" + ) + .ok(); + } + + if spoilers_toggled != 0 { + writeln!( + &mut response, + "[Úspěch] Přepnuty spoilery: {spoilers_toggled}" + ) + .ok(); + } + + if stickies_toggled != 0 { + writeln!( + &mut response, + "[Úspěch] Připnuty/odepnuty příspěvky: {stickies_toggled}" + ) + .ok(); + } + + if locks_toggled != 0 { + writeln!( + &mut response, + "[Úspěch] Zamčena/odemčena vlákna: {locks_toggled}" + ) + .ok(); + } + + if reports_removed != 0 { + writeln!( + &mut response, + "[Úspěch] Odstraněna hlášení: {reports_removed}" + ) + .ok(); + } + + if users_trolled != 0 { + writeln!( + &mut response, + "[Úspěch] Vytroleni uživatelé: {users_trolled}" + ) + .ok(); + } + + if bans_issued != 0 { + writeln!(&mut response, "[Úspěch] Uděleny bany: {bans_issued}").ok(); + } + + let template = ActionTemplate { tcx, response }; + + template_response(&template) +} + +#[allow(clippy::too_many_arguments)] +async fn ban_ip( + ctx: &Ctx, + account: &Account, + form: &StaffPostActionsForm, + ip: IpAddr, + board: String, + reason: String, + duration: u64, + range: &str, +) -> Result<(), NekrochanError> { + let account = account.username.clone(); + + let board = if form.global_ban.is_none() { + Some(board) + } else { + None + }; + + let prefix = if ip.is_ipv4() { + match range { + "lan" => 24, + "isp" => 16, + _ => 32, + } + } else { + match range { + "lan" => 48, + "isp" => 24, + _ => 128, + } + }; + + let ip_range = IpNetwork::new(ip, prefix)?; + let reason: String = reason.trim().into(); + + if reason.is_empty() || reason.len() > 200 { + return Err(NekrochanError::BanReasonFormatError); + } + + let appealable = form.unappealable_ban.is_none(); + + let expires = if duration == 0 { + None + } else { + Some(Utc::now() + Duration::days(duration as i64)) + }; + + Ban::create(ctx, account, board, ip_range, reason, appealable, expires).await?; + + Ok(()) +} diff --git a/src/web/actions/user_post_actions.rs b/src/web/actions/user_post_actions.rs new file mode 100644 index 0000000..6c9f580 --- /dev/null +++ b/src/web/actions/user_post_actions.rs @@ -0,0 +1,135 @@ +use actix_web::{post, web::Data, HttpRequest, HttpResponse}; +use pwhash::bcrypt::verify; +use serde::Deserialize; +use std::fmt::Write; + +use crate::{ + ctx::Ctx, + db::models::{Ban, Board}, + error::NekrochanError, + qsform::QsForm, + web::{ + actions::{get_posts_from_ids, ActionTemplate}, + ban_response, + tcx::{ip_from_req, TemplateCtx}, + template_response, + }, +}; + +#[derive(Deserialize)] +pub struct UserPostActionsForm { + #[serde(default)] + pub posts: Vec, + pub remove_posts: Option, + pub remove_files: Option, + pub toggle_spoiler: Option, + #[serde(rename = "post_password")] + pub password: String, +} + +#[post("/actions/user-post-actions")] +pub async fn user_post_actions( + ctx: Data, + req: HttpRequest, + QsForm(form): QsForm, +) -> Result { + let tcx = TemplateCtx::new(&ctx, &req).await?; + let (ip, _) = ip_from_req(&req)?; + let bans = Ban::read_by_ip(&ctx, ip).await?; + + if let Some(ban) = bans.get(&None) { + if !(tcx.perms.owner() || tcx.perms.bypass_bans()) { + return ban_response(&ctx, &req, ban.clone()).await; + } + } + + let posts = get_posts_from_ids(&ctx, &form.posts).await; + let boards = Board::read_all_map(&ctx).await?; + + let mut response = String::new(); + + let mut posts_removed = 0; + let mut files_removed = 0; + let mut spoilers_toggled = 0; + + for post in &posts { + let board = &boards[&post.board]; + + if bans.contains_key(&Some(board.id.clone())) + && !(tcx.perms.owner() || tcx.perms.bypass_bans()) + { + writeln!(&mut response, "[Chyba] Jsi zabanován z /{}/.", board.id).ok(); + continue; + } + + if board.config.0.locked && !(tcx.perms.owner() || tcx.perms.bypass_board_lock()) { + writeln!( + &mut response, + "[Chyba] {}", + NekrochanError::BoardLockError(board.id.clone()) + ) + .ok(); + + continue; + } + + if !verify(&form.password, &post.password) { + writeln!( + &mut response, + "[Chyba] {}", + NekrochanError::IncorrectPasswordError(post.id) + ) + .ok(); + continue; + } + + if form.remove_posts.is_some() { + post.delete(&ctx).await?; + posts_removed += 1; + } + + if form.remove_files.is_some() { + if (post.thread.is_none() && board.config.0.require_thread_file) + || (post.thread.is_some() && board.config.0.require_reply_file) + { + writeln!(&mut response, "[Chyba] Soubor je na tomto místě potřebný.").ok(); + } else { + post.delete_files(&ctx).await?; + files_removed += post.files.0.len(); + } + } + + if form.toggle_spoiler.is_some() { + post.update_spoiler(&ctx).await?; + spoilers_toggled += post.files.0.len(); + } + } + + if posts_removed != 0 { + writeln!( + &mut response, + "[Úspěch] Odstraněny příspěvky: {posts_removed}" + ) + .ok(); + } + + if files_removed != 0 { + writeln!( + &mut response, + "[Úspěch] Odstraněny soubory: {files_removed}" + ) + .ok(); + } + + if spoilers_toggled != 0 { + writeln!( + &mut response, + "[Úspěch] Přepnuty spoilery: {spoilers_toggled}" + ) + .ok(); + } + + let template = ActionTemplate { tcx, response }; + + template_response(&template) +} diff --git a/src/web/board.rs b/src/web/board.rs new file mode 100755 index 0000000..d047743 --- /dev/null +++ b/src/web/board.rs @@ -0,0 +1,74 @@ +use actix_web::{ + get, + web::{Data, Path, Query}, + HttpRequest, HttpResponse, +}; +use askama::Template; +use redis::AsyncCommands; +use serde::Deserialize; + +use crate::{ + check_page, + ctx::Ctx, + db::models::{Board, Post}, + error::NekrochanError, + filters, paginate, + web::{tcx::TemplateCtx, template_response}, +}; + +#[derive(Deserialize)] +pub struct BoardQuery { + page: i64, +} + +#[derive(Template)] +#[template(path = "board.html")] +struct BoardTemplate { + tcx: TemplateCtx, + board: Board, + threads: Vec<(Post, Vec)>, + page: i64, + pages: i64, +} + +#[get("/boards/{board}")] +pub async fn board( + ctx: Data, + req: HttpRequest, + path: Path, + query: Option>, +) -> Result { + let tcx = TemplateCtx::new(&ctx, &req).await?; + + let board = path.into_inner(); + let board = Board::read(&ctx, board.clone()) + .await? + .ok_or(NekrochanError::BoardNotFound(board))?; + + let count = ctx + .cache() + .get(format!("board_threads:{}", board.id)) + .await?; + let page = query.map_or(1, |q| q.page); + let pages = paginate(board.config.0.page_size, count); + + check_page(page, pages, board.config.0.page_count)?; + + let mut threads = Vec::new(); + + for thread in Post::read_board_page(&ctx, &board, page).await? { + let replies = thread.read_replies(&ctx).await?; + + threads.push((thread, replies)); + } + + let template = BoardTemplate { + tcx, + board, + threads, + page, + pages, + }; + + template_response(&template) +} diff --git a/src/web/board_catalog.rs b/src/web/board_catalog.rs new file mode 100644 index 0000000..31b7584 --- /dev/null +++ b/src/web/board_catalog.rs @@ -0,0 +1,46 @@ +use actix_web::{ + get, + web::{Data, Path}, + HttpRequest, HttpResponse, +}; +use askama::Template; + +use crate::{ + ctx::Ctx, + db::models::{Board, Post}, + error::NekrochanError, + filters, + web::{tcx::TemplateCtx, template_response}, +}; + +#[derive(Template)] +#[template(path = "board-catalog.html")] +struct BoardCatalogTemplate { + tcx: TemplateCtx, + board: Board, + threads: Vec, +} + +#[get("/boards/{board}/catalog")] +pub async fn board_catalog( + ctx: Data, + req: HttpRequest, + path: Path, +) -> Result { + let tcx = TemplateCtx::new(&ctx, &req).await?; + + let board = path.into_inner(); + let board = Board::read(&ctx, board.clone()) + .await? + .ok_or(NekrochanError::BoardNotFound(board))?; + + let threads = Post::read_board_catalog(&ctx, board.id.clone()).await?; + + let template = BoardCatalogTemplate { + tcx, + board, + threads, + }; + + template_response(&template) +} diff --git a/src/web/captcha.rs b/src/web/captcha.rs new file mode 100644 index 0000000..1eb8c9f --- /dev/null +++ b/src/web/captcha.rs @@ -0,0 +1,55 @@ +use ::captcha::{gen, Difficulty}; +use actix_web::{ + get, + web::{Data, Json, Query}, +}; +use redis::AsyncCommands; +use serde::{Deserialize, Serialize}; +use sha256::digest; + +use crate::{ctx::Ctx, db::models::Board, error::NekrochanError}; + +#[derive(Deserialize)] +pub struct CaptchaQuery { + pub board: String, + pub reply: bool, +} + +#[derive(Serialize)] +pub struct CaptchaResponse { + pub png: String, + pub id: String, +} + +#[get("/captcha")] +pub async fn captcha( + ctx: Data, + Query(query): Query, +) -> Result, NekrochanError> { + let board = Board::read(&ctx, query.board.clone()) + .await? + .ok_or(NekrochanError::BoardNotFound(query.board))?; + + let captcha = match board.config.thread_captcha.as_str() { + "easy" => gen(Difficulty::Easy), + "medium" => gen(Difficulty::Medium), + "hard" => gen(Difficulty::Hard), + _ => return Err(NekrochanError::NoCaptchaError), + }; + + // >YOU NEED TO MAKE A NEW ERROR TYPE FOR THIS ERROR THAT CAN ONLY HAPPEN ONCE IN THE CODE OR HOWEVER THE TRANS CHILDREN ARE PROTECTED + let png = captcha.as_base64().ok_or(NekrochanError::NoCaptchaError)?; + + let board = board.id; + let id = digest(png.as_bytes()); + + let key = format!("captcha:{board}:{id}"); + let solution = captcha.chars_as_string(); + + ctx.cache().set(&key, solution).await?; + ctx.cache().expire(&key, 600).await?; + + let res = CaptchaResponse { png, id }; + + Ok(Json(res)) +} diff --git a/src/web/edit_posts.rs b/src/web/edit_posts.rs new file mode 100644 index 0000000..e9fabbc --- /dev/null +++ b/src/web/edit_posts.rs @@ -0,0 +1,42 @@ +use actix_web::{post, web::Data, HttpRequest, HttpResponse}; +use askama::Template; +use serde::Deserialize; + +use crate::{ + ctx::Ctx, + db::models::Post, + error::NekrochanError, + qsform::QsForm, + web::{actions::get_posts_from_ids, template_response, TemplateCtx}, +}; + +#[derive(Deserialize)] +pub struct EditPostsForm { + #[serde(default)] + pub posts: Vec, +} + +#[derive(Template)] +#[template(path = "edit-posts.html")] +struct EditPostsTemplate { + tcx: TemplateCtx, + posts: Vec, +} + +#[post("/edit-posts")] +pub async fn edit_posts( + ctx: Data, + req: HttpRequest, + QsForm(form): QsForm, +) -> Result { + let tcx = TemplateCtx::new(&ctx, &req).await?; + + if !(tcx.perms.owner() || tcx.perms.edit_posts()) { + return Err(NekrochanError::InsufficientPermissionError); + } + + let posts = get_posts_from_ids(&ctx, &form.posts).await; + let template = EditPostsTemplate { tcx, posts }; + + template_response(&template) +} diff --git a/src/web/index.rs b/src/web/index.rs new file mode 100755 index 0000000..7c5a97f --- /dev/null +++ b/src/web/index.rs @@ -0,0 +1,43 @@ +use actix_web::{get, web::Data, HttpRequest, HttpResponse}; +use askama::Template; + +use super::tcx::TemplateCtx; +use crate::{ + ctx::Ctx, + db::models::{Board, LocalStats, NewsPost}, + error::NekrochanError, + filters, + web::template_response, +}; + +#[derive(Template)] +#[template(path = "index.html")] + +struct IndexTemplate { + tcx: TemplateCtx, + news: Option, + boards: Vec, + stats: LocalStats, +} + +#[get("/")] +pub async fn index(ctx: Data, req: HttpRequest) -> Result { + let tcx = TemplateCtx::new(&ctx, &req).await?; + + if tcx.boards.is_empty() { + return Err(NekrochanError::HomePageError); + } + + let news = NewsPost::read_latest(&ctx).await?; + let boards = Board::read_all(&ctx).await?; + let stats = LocalStats::read(&ctx).await?; + + let template = IndexTemplate { + tcx, + boards, + stats, + news, + }; + + template_response(&template) +} diff --git a/src/web/ip_posts.rs b/src/web/ip_posts.rs new file mode 100644 index 0000000..db3a1e5 --- /dev/null +++ b/src/web/ip_posts.rs @@ -0,0 +1,65 @@ +use actix_web::{ + get, + web::{Data, Path, Query}, + HttpRequest, HttpResponse, +}; +use askama::Template; +use serde::Deserialize; +use std::{collections::HashMap, net::IpAddr}; + +use crate::{ + ctx::Ctx, + db::models::{Board, Post}, + error::NekrochanError, + filters, + web::{tcx::TemplateCtx, template_response}, +}; + +#[derive(Deserialize)] +pub struct IpPostsQuery { + page: i64, +} + +#[derive(Template)] +#[template(path = "ip-posts.html")] +struct IpPostsTemplate { + tcx: TemplateCtx, + ip: IpAddr, + boards: HashMap, + posts: Vec, + page: i64, +} + +#[get("/ip-posts/{ip}")] +pub async fn ip_posts( + ctx: Data, + req: HttpRequest, + path: Path, + query: Option>, +) -> Result { + let tcx = TemplateCtx::new(&ctx, &req).await?; + + if !(tcx.perms.owner() || tcx.perms.view_ips()) { + return Err(NekrochanError::InsufficientPermissionError); + } + + let ip = path.into_inner(); + let boards = Board::read_all_map(&ctx).await?; + let page = query.map_or(1, |q| q.page); + + if page <= 0 { + return Err(NekrochanError::InvalidPageError); + } + + let posts = Post::read_ip_page(&ctx, ip, page).await?; + + let template = IpPostsTemplate { + tcx, + ip, + boards, + posts, + page, + }; + + template_response(&template) +} diff --git a/src/web/live.rs b/src/web/live.rs new file mode 100644 index 0000000..bb5c574 --- /dev/null +++ b/src/web/live.rs @@ -0,0 +1,62 @@ +use actix_web::{ + get, + web::{Data, Path, Payload}, + HttpRequest, HttpResponse, +}; +use actix_web_actors::ws; +use uuid::Uuid; + +use crate::{ + ctx::Ctx, + db::models::{Board, Post}, + error::NekrochanError, + live_hub::TargetedPostCreatedMessage, + live_session::LiveSession, + web::tcx::TemplateCtx, +}; + +#[get("/live/{board}/{id}/{last}")] +pub async fn live( + ctx: Data, + req: HttpRequest, + path: Path<(String, i64, i64)>, + stream: Payload, +) -> Result { + let (board, id, last) = path.into_inner(); + + let board = Board::read(&ctx, board.clone()) + .await? + .ok_or(NekrochanError::BoardNotFound(board))?; + + let post = Post::read(&ctx, board.id.clone(), id) + .await? + .ok_or(NekrochanError::PostNotFound(board.id.clone(), id))?; + + if post.thread.is_some() { + return Err(NekrochanError::IsReplyError); + } + + let uuid = Uuid::new_v4(); + let thread = (board.id, id); + let tcx = TemplateCtx::new(&ctx, &req).await?; + let hub = ctx.hub(); + + let ws = LiveSession { + uuid, + thread, + tcx, + hub, + }; + + let res = ws::start(ws, &req, stream)?; + + let new_replies = post.read_replies_after(&ctx, last).await?; + + for post in new_replies { + ctx.hub() + .send(TargetedPostCreatedMessage { uuid, post }) + .await?; + } + + Ok(res) +} diff --git a/src/web/login.rs b/src/web/login.rs new file mode 100755 index 0000000..f717a7e --- /dev/null +++ b/src/web/login.rs @@ -0,0 +1,59 @@ +use actix_web::{ + cookie::Cookie, get, http::StatusCode, post, web::Data, HttpRequest, HttpResponse, + HttpResponseBuilder, +}; +use askama::Template; +use pwhash::bcrypt::verify; +use serde::Deserialize; + +use crate::{ + auth::Claims, + ctx::Ctx, + db::models::Account, + error::NekrochanError, + qsform::QsForm, + web::{tcx::TemplateCtx, template_response}, +}; + +#[derive(Template)] +#[template(path = "login.html")] +struct LogInTemplate { + tcx: TemplateCtx, +} + +#[get("/login")] +pub async fn login_get(ctx: Data, req: HttpRequest) -> Result { + let tcx = TemplateCtx::new(&ctx, &req).await?; + let template = LogInTemplate { tcx }; + + template_response(&template) +} + +#[derive(Deserialize)] +pub struct LogInForm { + username: String, + password: String, +} + +#[post("/login")] +pub async fn login_post( + ctx: Data, + QsForm(form): QsForm, +) -> Result { + let account = Account::read(&ctx, form.username.clone()) + .await? + .ok_or(NekrochanError::IncorrectCredentialError)?; + + if !verify(form.password, &account.password) { + return Err(NekrochanError::IncorrectCredentialError); + } + + let auth = Claims::new(account.username).encode(&ctx)?; + + let res = HttpResponseBuilder::new(StatusCode::SEE_OTHER) + .append_header(("Location", "/staff/account")) + .cookie(Cookie::new("auth", auth)) + .finish(); + + Ok(res) +} diff --git a/src/web/logout.rs b/src/web/logout.rs new file mode 100755 index 0000000..f9c8d06 --- /dev/null +++ b/src/web/logout.rs @@ -0,0 +1,13 @@ +use actix_web::{cookie::Cookie, get, http::StatusCode, HttpResponse, HttpResponseBuilder}; + +#[get("/logout")] +pub async fn logout() -> HttpResponse { + let mut auth = Cookie::named("auth"); + + auth.make_removal(); + + HttpResponseBuilder::new(StatusCode::SEE_OTHER) + .append_header(("Location", "/")) + .cookie(auth) + .finish() +} diff --git a/src/web/mod.rs b/src/web/mod.rs new file mode 100755 index 0000000..25a4240 --- /dev/null +++ b/src/web/mod.rs @@ -0,0 +1,53 @@ +pub mod actions; +pub mod board; +pub mod board_catalog; +pub mod captcha; +pub mod edit_posts; +pub mod index; +pub mod ip_posts; +pub mod live; +pub mod login; +pub mod logout; +pub mod news; +pub mod overboard; +pub mod overboard_catalog; +pub mod page; +pub mod search; +pub mod staff; +pub mod tcx; +pub mod thread; +pub mod thread_json; + +use actix_web::{http::StatusCode, HttpRequest, HttpResponse, HttpResponseBuilder}; +use askama::Template; + +use self::tcx::TemplateCtx; +use crate::{ctx::Ctx, db::models::Ban, error::NekrochanError, filters}; + +#[derive(Template)] +#[template(path = "banned.html")] +struct BannedTemplate { + tcx: TemplateCtx, + ban: Ban, +} + +pub async fn ban_response( + ctx: &Ctx, + req: &HttpRequest, + ban: Ban, +) -> Result { + let tcx = TemplateCtx::new(ctx, req).await?; + + template_response(&BannedTemplate { tcx, ban }) +} + +pub fn template_response(template: &T) -> Result +where + T: Template, +{ + let res = HttpResponseBuilder::new(StatusCode::OK) + .append_header(("Content-Type", "text/html")) + .body(template.render()?); + + Ok(res) +} diff --git a/src/web/news.rs b/src/web/news.rs new file mode 100644 index 0000000..494942e --- /dev/null +++ b/src/web/news.rs @@ -0,0 +1,21 @@ +use actix_web::{get, web::Data, HttpRequest, HttpResponse}; +use askama::Template; + +use super::{tcx::TemplateCtx, template_response}; +use crate::{ctx::Ctx, db::models::NewsPost, error::NekrochanError, filters}; + +#[derive(Template)] +#[template(path = "news.html")] +struct NewsTemplate { + tcx: TemplateCtx, + news: Vec, +} + +#[get("/news")] +pub async fn news(ctx: Data, req: HttpRequest) -> Result { + let tcx = TemplateCtx::new(&ctx, &req).await?; + let news = NewsPost::read_all(&ctx).await?; + let template = NewsTemplate { tcx, news }; + + template_response(&template) +} diff --git a/src/web/overboard.rs b/src/web/overboard.rs new file mode 100644 index 0000000..82bbe16 --- /dev/null +++ b/src/web/overboard.rs @@ -0,0 +1,68 @@ +use actix_web::{ + get, + web::{Data, Query}, + HttpRequest, HttpResponse, +}; +use askama::Template; +use redis::AsyncCommands; +use serde::Deserialize; +use std::collections::HashMap; + +use crate::{ + check_page, + ctx::Ctx, + db::models::{Board, Post}, + error::NekrochanError, + filters, paginate, + web::{tcx::TemplateCtx, template_response}, + GENERIC_PAGE_SIZE, +}; + +#[derive(Deserialize)] +pub struct OverboardQuery { + page: i64, +} + +#[derive(Template)] +#[template(path = "overboard.html")] +struct OverboardTemplate { + tcx: TemplateCtx, + boards: HashMap, + threads: Vec<(Post, Vec)>, + page: i64, + pages: i64, +} + +#[get("/overboard")] +pub async fn overboard( + ctx: Data, + req: HttpRequest, + query: Option>, +) -> Result { + let boards = Board::read_all_map(&ctx).await?; + let tcx = TemplateCtx::new(&ctx, &req).await?; + + let count = ctx.cache().get("total_threads").await?; + let page = query.map_or(1, |q| q.page); + let pages = paginate(GENERIC_PAGE_SIZE, count); + + check_page(page, pages, None)?; + + let mut threads = Vec::new(); + + for thread in Post::read_overboard_page(&ctx, page).await? { + let replies = thread.read_replies(&ctx).await?; + + threads.push((thread, replies)); + } + + let template = OverboardTemplate { + tcx, + boards, + threads, + page, + pages, + }; + + template_response(&template) +} diff --git a/src/web/overboard_catalog.rs b/src/web/overboard_catalog.rs new file mode 100644 index 0000000..afef01e --- /dev/null +++ b/src/web/overboard_catalog.rs @@ -0,0 +1,30 @@ +use actix_web::{get, web::Data, HttpRequest, HttpResponse}; +use askama::Template; + +use crate::{ + ctx::Ctx, + db::models::Post, + error::NekrochanError, + filters, + web::{tcx::TemplateCtx, template_response}, +}; + +#[derive(Template)] +#[template(path = "overboard-catalog.html")] +struct OverboardCatalogTemplate { + tcx: TemplateCtx, + threads: Vec, +} + +#[get("/overboard/catalog")] +pub async fn overboard_catalog( + ctx: Data, + req: HttpRequest, +) -> Result { + let tcx = TemplateCtx::new(&ctx, &req).await?; + let threads = Post::read_overboard_catalog(&ctx).await?; + + let template = OverboardCatalogTemplate { tcx, threads }; + + template_response(&template) +} diff --git a/src/web/page.rs b/src/web/page.rs new file mode 100644 index 0000000..06d3f6b --- /dev/null +++ b/src/web/page.rs @@ -0,0 +1,36 @@ +use actix_web::{ + get, + web::{Data, Path}, + HttpRequest, HttpResponse, +}; +use askama::Template; +use tokio::fs::read_to_string; + +use crate::{ctx::Ctx, error::NekrochanError, web::template_response}; + +use super::tcx::TemplateCtx; + +#[derive(Template)] +#[template(path = "page.html")] +struct PageTemplate { + pub tcx: TemplateCtx, + pub name: String, + pub content: String, +} + +#[get("/page/{name}")] +pub async fn page( + ctx: Data, + req: HttpRequest, + name: Path, +) -> Result { + let tcx = TemplateCtx::new(&ctx, &req).await?; + let name = name.into_inner(); + let content = read_to_string(format!("./pages/{name}.html")) + .await + .map_err(|_| NekrochanError::PageNotFound(name.clone()))?; + + let template = PageTemplate { tcx, name, content }; + + template_response(&template) +} diff --git a/src/web/search.rs b/src/web/search.rs new file mode 100644 index 0000000..10e1b25 --- /dev/null +++ b/src/web/search.rs @@ -0,0 +1,88 @@ +use actix_web::{ + get, + web::{Data, Query}, + HttpRequest, HttpResponse, +}; +use askama::Template; +use serde::Deserialize; +use std::collections::HashMap; + +use super::tcx::TemplateCtx; +use crate::{ + ctx::Ctx, + db::models::{Board, Post}, + error::NekrochanError, + filters, web::template_response, +}; + +#[derive(Template)] +#[template(path = "search.html")] +struct SearchTemplate { + tcx: TemplateCtx, + board_opt: Option, + boards: HashMap, + query: String, + posts: Vec, + page: i64, +} + +#[derive(Deserialize)] +pub struct SearchQuery { + board: Option, + query: String, + page: Option, +} + +#[get("/search")] +pub async fn search( + ctx: Data, + req: HttpRequest, + Query(query): Query, +) -> Result { + let tcx = TemplateCtx::new(&ctx, &req).await?; + + let board_opt = if let Some(board) = query.board { + let board = Board::read(&ctx, board.clone()) + .await? + .ok_or(NekrochanError::BoardNotFound(board))?; + + Some(board) + } else { + None + }; + + let boards = if board_opt.is_none() { + Board::read_all_map(&ctx).await? + } else { + HashMap::new() + }; + + let page = query.page.unwrap_or(1); + + if page <= 0 { + return Err(NekrochanError::InvalidPageError); + } + + let query = query.query; + + if query.is_empty() || query.len() > 256 { + return Err(NekrochanError::QueryFormatError); + } + + let posts = if let Some(board) = &board_opt { + Post::read_by_query(&ctx, board, query.clone(), page).await? + } else { + Post::read_by_query_overboard(&ctx, query.clone(), page).await? + }; + + let template = SearchTemplate { + tcx, + board_opt, + boards, + query, + posts, + page, + }; + + template_response(&template) +} diff --git a/src/web/staff/account.rs b/src/web/staff/account.rs new file mode 100755 index 0000000..fcf4133 --- /dev/null +++ b/src/web/staff/account.rs @@ -0,0 +1,28 @@ +use actix_web::{get, web::Data, HttpRequest, HttpResponse}; +use askama::Template; + +use crate::{ + ctx::Ctx, + db::models::Account, + error::NekrochanError, + web::{ + tcx::{account_from_auth, TemplateCtx}, + template_response, + }, +}; + +#[derive(Template)] +#[template(path = "staff/account.html")] +struct AccountTemplate { + tcx: TemplateCtx, + account: Account, +} + +#[get("/staff/account")] +pub async fn account(ctx: Data, req: HttpRequest) -> Result { + let tcx = TemplateCtx::new(&ctx, &req).await?; + let account = account_from_auth(&ctx, &req).await?; + let template = AccountTemplate { tcx, account }; + + template_response(&template) +} diff --git a/src/web/staff/accounts.rs b/src/web/staff/accounts.rs new file mode 100755 index 0000000..e5ffd4e --- /dev/null +++ b/src/web/staff/accounts.rs @@ -0,0 +1,31 @@ +use actix_web::{get, web::Data, HttpRequest, HttpResponse}; +use askama::Template; + +use crate::{ + ctx::Ctx, + db::models::Account, + error::NekrochanError, + filters, + web::{tcx::TemplateCtx, template_response}, +}; + +#[derive(Template)] +#[template(path = "staff/accounts.html")] +struct AccountsTemplate { + tcx: TemplateCtx, + accounts: Vec, +} + +#[get("/staff/accounts")] +pub async fn accounts(ctx: Data, req: HttpRequest) -> Result { + let tcx = TemplateCtx::new(&ctx, &req).await?; + + if tcx.account.is_none() { + return Err(NekrochanError::NotLoggedInError); + } + + let accounts = Account::read_all(&ctx).await?; + let template = AccountsTemplate { tcx, accounts }; + + template_response(&template) +} diff --git a/src/web/staff/actions/add_banners.rs b/src/web/staff/actions/add_banners.rs new file mode 100755 index 0000000..5e97775 --- /dev/null +++ b/src/web/staff/actions/add_banners.rs @@ -0,0 +1,42 @@ +use actix_multipart::form::{tempfile::TempFile, MultipartForm}; +use actix_web::{post, web::Data, HttpRequest, HttpResponse}; + +use crate::{ + ctx::Ctx, + db::models::{Banner, File}, + error::NekrochanError, + web::tcx::account_from_auth, +}; + +#[derive(MultipartForm)] +pub struct AddBannersForm { + #[multipart(rename = "files[]")] + files: Vec, +} + +#[post("/staff/actions/add-banners")] +pub async fn add_banners( + ctx: Data, + req: HttpRequest, + MultipartForm(form): MultipartForm, +) -> Result { + let account = account_from_auth(&ctx, &req).await?; + + if !(account.perms().owner() || account.perms().banners()) { + return Err(NekrochanError::InsufficientPermissionError); + } + + let mut cfg = ctx.cfg.clone(); + + cfg.files.videos = false; + + for file in form.files { + Banner::create(&ctx, File::new(&cfg, file, false, false).await?).await?; + } + + let res = HttpResponse::SeeOther() + .append_header(("Location", "/staff/banners")) + .finish(); + + Ok(res) +} diff --git a/src/web/staff/actions/change_password.rs b/src/web/staff/actions/change_password.rs new file mode 100755 index 0000000..0c7f62c --- /dev/null +++ b/src/web/staff/actions/change_password.rs @@ -0,0 +1,38 @@ +use actix_web::{post, web::Data, HttpRequest, HttpResponse}; +use pwhash::bcrypt::{hash, verify}; +use serde::Deserialize; + +use crate::{ctx::Ctx, error::NekrochanError, qsform::QsForm, web::tcx::account_from_auth}; + +#[derive(Deserialize)] +pub struct ChangePasswordForm { + old_password: String, + new_password: String, +} + +#[post("/staff/actions/change-password")] +pub async fn change_password( + ctx: Data, + req: HttpRequest, + QsForm(form): QsForm, +) -> Result { + let account = account_from_auth(&ctx, &req).await?; + + if !verify(form.old_password, &account.password) { + return Err(NekrochanError::IncorrectCredentialError); + } + + if form.new_password.len() < 8 { + return Err(NekrochanError::PasswordFormatError); + } + + let password = hash(form.new_password)?; + + account.update_password(&ctx, password).await?; + + let res = HttpResponse::SeeOther() + .append_header(("Location", "/staff/account")) + .finish(); + + Ok(res) +} diff --git a/src/web/staff/actions/create_account.rs b/src/web/staff/actions/create_account.rs new file mode 100755 index 0000000..e86b8c6 --- /dev/null +++ b/src/web/staff/actions/create_account.rs @@ -0,0 +1,49 @@ +use actix_web::{post, web::Data, HttpRequest, HttpResponse}; +use pwhash::bcrypt::hash; +use serde::Deserialize; + +use crate::{ + ctx::Ctx, db::models::Account, error::NekrochanError, qsform::QsForm, + web::tcx::account_from_auth, +}; + +#[derive(Deserialize)] +pub struct CreateAccountForm { + username: String, + #[serde(rename = "account_password")] + password: String, +} + +#[post("/staff/actions/create-account")] +pub async fn create_account( + ctx: Data, + req: HttpRequest, + QsForm(form): QsForm, +) -> Result { + let account = account_from_auth(&ctx, &req).await?; + + if !account.perms().owner() { + return Err(NekrochanError::InsufficientPermissionError); + } + + let username = form.username.trim().to_owned(); + let password = form.password.trim().to_owned(); + + if username.is_empty() || username.len() > 32 { + return Err(NekrochanError::UsernameFormatError); + } + + if password.len() < 8 { + return Err(NekrochanError::PasswordFormatError); + } + + let password = hash(password)?; + + let _ = Account::create(&ctx, username, password).await?; + + let res = HttpResponse::SeeOther() + .append_header(("Location", "/staff/accounts")) + .finish(); + + Ok(res) +} diff --git a/src/web/staff/actions/create_board.rs b/src/web/staff/actions/create_board.rs new file mode 100755 index 0000000..d24ec98 --- /dev/null +++ b/src/web/staff/actions/create_board.rs @@ -0,0 +1,56 @@ +use actix_web::{post, web::Data, HttpRequest, HttpResponse}; +use lazy_static::lazy_static; +use regex::Regex; +use serde::Deserialize; + +use crate::{ + ctx::Ctx, db::models::Board, error::NekrochanError, qsform::QsForm, web::tcx::account_from_auth, +}; + +lazy_static! { + static ref ID_REGEX: Regex = Regex::new(r"^\w{1,16}$").unwrap(); +} + +#[derive(Deserialize)] +pub struct CreateBoardForm { + id: String, + name: String, + description: String, +} + +#[post("/staff/actions/create-board")] +pub async fn create_board( + ctx: Data, + req: HttpRequest, + QsForm(form): QsForm, +) -> Result { + let account = account_from_auth(&ctx, &req).await?; + + if !account.perms().owner() { + return Err(NekrochanError::InsufficientPermissionError); + } + + let id = form.id.trim().to_owned(); + let name = form.name.trim().to_owned(); + let description = form.description.trim().to_owned(); + + if !ID_REGEX.is_match(&id) { + return Err(NekrochanError::IdFormatError); + } + + if name.is_empty() || name.len() > 32 { + return Err(NekrochanError::BoardNameFormatError); + } + + if description.len() > 128 { + return Err(NekrochanError::DescriptionFormatError); + } + + let _ = Board::create(&ctx, id, name, description).await?; + + let res = HttpResponse::SeeOther() + .append_header(("Location", "/staff/boards")) + .finish(); + + Ok(res) +} diff --git a/src/web/staff/actions/create_news.rs b/src/web/staff/actions/create_news.rs new file mode 100644 index 0000000..0b1a07f --- /dev/null +++ b/src/web/staff/actions/create_news.rs @@ -0,0 +1,48 @@ +use actix_web::{post, web::Data, HttpRequest, HttpResponse}; +use serde::Deserialize; + +use crate::{ + ctx::Ctx, db::models::NewsPost, error::NekrochanError, markup::markup, qsform::QsForm, + web::tcx::account_from_auth, +}; + +#[derive(Deserialize)] +pub struct CreateNewsForm { + title: String, + content: String, +} + +#[post("/staff/actions/create-news")] +pub async fn create_news( + ctx: Data, + req: HttpRequest, + QsForm(form): QsForm, +) -> Result { + let account = account_from_auth(&ctx, &req).await?; + + if !(account.perms().owner() || account.perms().news()) { + return Err(NekrochanError::InsufficientPermissionError); + } + + let title = form.title.trim().to_owned(); + let content = form.content.trim().to_owned(); + + if title.is_empty() || title.len() > 100 { + return Err(NekrochanError::NewsTitleFormatError); + } + + if content.is_empty() || content.len() > 10000 { + return Err(NekrochanError::NewsContentFormatError); + } + + let content_nomarkup = content; + let (content, _) = markup(&ctx, &account.perms(), None, None, &content_nomarkup).await?; + + NewsPost::create(&ctx, title, content, content_nomarkup, account.username).await?; + + let res = HttpResponse::SeeOther() + .append_header(("Location", "/staff/news")) + .finish(); + + Ok(res) +} diff --git a/src/web/staff/actions/delete_account.rs b/src/web/staff/actions/delete_account.rs new file mode 100755 index 0000000..1857a9b --- /dev/null +++ b/src/web/staff/actions/delete_account.rs @@ -0,0 +1,23 @@ +use actix_web::{post, web::Data, HttpRequest, HttpResponse}; + +use crate::{ctx::Ctx, error::NekrochanError, web::tcx::account_from_auth}; + +#[post("/staff/actions/delete-account")] +pub async fn delete_account( + ctx: Data, + req: HttpRequest, +) -> Result { + let account = account_from_auth(&ctx, &req).await?; + + if account.perms().owner() { + return Err(NekrochanError::OwnerDeletionError); + } + + account.delete(&ctx).await?; + + let res = HttpResponse::SeeOther() + .append_header(("Location", "/logout")) + .finish(); + + Ok(res) +} diff --git a/src/web/staff/actions/edit_news.rs b/src/web/staff/actions/edit_news.rs new file mode 100644 index 0000000..a38ce56 --- /dev/null +++ b/src/web/staff/actions/edit_news.rs @@ -0,0 +1,71 @@ +use actix_web::{post, web::Data, HttpRequest, HttpResponse}; +use std::{collections::HashMap, fmt::Write}; + +use crate::{ + ctx::Ctx, + db::models::NewsPost, + error::NekrochanError, + markup::markup, + qsform::QsForm, + web::{actions::ActionTemplate, tcx::TemplateCtx, template_response}, +}; + +#[post("/staff/actions/edit-news")] +pub async fn edit_news( + ctx: Data, + req: HttpRequest, + QsForm(edits): QsForm>, +) -> Result { + let tcx = TemplateCtx::new(&ctx, &req).await?; + + if !(tcx.perms.owner() || tcx.perms.news()) { + return Err(NekrochanError::InsufficientPermissionError); + } + + let mut news = Vec::new(); + + for id in edits.keys() { + if let Some(newspost) = NewsPost::read(&ctx, *id).await? { + news.push(newspost); + } + } + + let news = news + .into_iter() + .map(|newspost| (newspost.id, newspost)) + .collect::>(); + + let mut response = String::new(); + let mut news_edited = 0; + + for (id, content_nomarkup) in edits { + let newspost = &news[&id]; + + if !tcx.perms.owner() && tcx.account != Some(newspost.author.clone()) { + writeln!( + &mut response, + "[Chyba] pouze vlastník nebo autor může upravit novinky." + ) + .ok(); + + continue; + } + + let content_nomarkup = content_nomarkup.trim(); + let (content, _) = markup(&ctx, &tcx.perms, None, None, content_nomarkup).await?; + + newspost + .update(&ctx, content, content_nomarkup.into()) + .await?; + + news_edited += 1; + } + + if news_edited != 0 { + writeln!(&mut response, "[Úspěch] Upraveny novinky: {news_edited}").ok(); + } + + let template = ActionTemplate { tcx, response }; + + template_response(&template) +} diff --git a/src/web/staff/actions/mod.rs b/src/web/staff/actions/mod.rs new file mode 100755 index 0000000..cb33f4f --- /dev/null +++ b/src/web/staff/actions/mod.rs @@ -0,0 +1,16 @@ +pub mod add_banners; +pub mod change_password; +pub mod create_account; +pub mod create_board; +pub mod create_news; +pub mod delete_account; +pub mod edit_news; +pub mod remove_accounts; +pub mod remove_banners; +pub mod remove_bans; +pub mod remove_boards; +pub mod remove_news; +pub mod transfer_ownership; +pub mod update_board_config; +pub mod update_boards; +pub mod update_permissions; diff --git a/src/web/staff/actions/remove_accounts.rs b/src/web/staff/actions/remove_accounts.rs new file mode 100755 index 0000000..6c63336 --- /dev/null +++ b/src/web/staff/actions/remove_accounts.rs @@ -0,0 +1,42 @@ +use actix_web::{post, web::Data, HttpRequest, HttpResponse}; +use serde::Deserialize; + +use crate::{ + ctx::Ctx, db::models::Account, error::NekrochanError, qsform::QsForm, + web::tcx::account_from_auth, +}; + +#[derive(Deserialize)] +pub struct RemoveAccountsForm { + #[serde(default)] + accounts: Vec, +} + +#[post("/staff/actions/remove-accounts")] +pub async fn remove_accounts( + ctx: Data, + req: HttpRequest, + QsForm(form): QsForm, +) -> Result { + let account = account_from_auth(&ctx, &req).await?; + + if !account.perms().owner() { + return Err(NekrochanError::InsufficientPermissionError); + } + + for account in form.accounts { + if let Some(account) = Account::read(&ctx, account).await? { + if account.owner { + return Err(NekrochanError::OwnerDeletionError); + } + + account.delete(&ctx).await?; + } + } + + let res = HttpResponse::SeeOther() + .append_header(("Location", "/staff/accounts")) + .finish(); + + Ok(res) +} diff --git a/src/web/staff/actions/remove_banners.rs b/src/web/staff/actions/remove_banners.rs new file mode 100755 index 0000000..9be373b --- /dev/null +++ b/src/web/staff/actions/remove_banners.rs @@ -0,0 +1,38 @@ +use actix_web::{post, web::Data, HttpRequest, HttpResponse}; +use serde::Deserialize; + +use crate::{ + ctx::Ctx, db::models::Banner, error::NekrochanError, qsform::QsForm, + web::tcx::account_from_auth, +}; + +#[derive(Deserialize)] +pub struct RemoveBannersForm { + #[serde(default)] + banners: Vec, +} + +#[post("/staff/actions/remove-banners")] +pub async fn remove_banners( + ctx: Data, + req: HttpRequest, + QsForm(form): QsForm, +) -> Result { + let account = account_from_auth(&ctx, &req).await?; + + if !(account.perms().owner() || account.perms().banners()) { + return Err(NekrochanError::InsufficientPermissionError); + } + + for id in form.banners { + if let Some(banner) = Banner::read(&ctx, id).await? { + banner.remove(&ctx).await?; + } + } + + let res = HttpResponse::SeeOther() + .append_header(("Location", "/staff/banners")) + .finish(); + + Ok(res) +} diff --git a/src/web/staff/actions/remove_bans.rs b/src/web/staff/actions/remove_bans.rs new file mode 100755 index 0000000..79f7736 --- /dev/null +++ b/src/web/staff/actions/remove_bans.rs @@ -0,0 +1,37 @@ +use actix_web::{post, web::Data, HttpRequest, HttpResponse}; +use serde::Deserialize; + +use crate::{ + ctx::Ctx, db::models::Ban, error::NekrochanError, qsform::QsForm, web::tcx::account_from_auth, +}; + +#[derive(Deserialize)] +pub struct RemoveBansForm { + #[serde(default)] + bans: Vec, +} + +#[post("/staff/actions/remove-bans")] +pub async fn remove_bans( + ctx: Data, + req: HttpRequest, + QsForm(form): QsForm, +) -> Result { + let account = account_from_auth(&ctx, &req).await?; + + if !(account.perms().owner() || account.perms().bans()) { + return Err(NekrochanError::InsufficientPermissionError); + } + + for ban in form.bans { + if let Some(ban) = Ban::read_by_id(&ctx, ban).await? { + ban.delete(&ctx).await?; + } + } + + let res = HttpResponse::SeeOther() + .append_header(("Location", "/staff/bans")) + .finish(); + + Ok(res) +} diff --git a/src/web/staff/actions/remove_boards.rs b/src/web/staff/actions/remove_boards.rs new file mode 100755 index 0000000..382ea71 --- /dev/null +++ b/src/web/staff/actions/remove_boards.rs @@ -0,0 +1,37 @@ +use actix_web::{post, web::Data, HttpRequest, HttpResponse}; +use serde::Deserialize; + +use crate::{ + ctx::Ctx, db::models::Board, error::NekrochanError, qsform::QsForm, web::tcx::account_from_auth, +}; + +#[derive(Deserialize)] +pub struct RemoveBoardsForm { + #[serde(default)] + boards: Vec, +} + +#[post("/staff/actions/remove-boards")] +pub async fn remove_boards( + ctx: Data, + req: HttpRequest, + QsForm(form): QsForm, +) -> Result { + let account = account_from_auth(&ctx, &req).await?; + + if !account.perms().owner() { + return Err(NekrochanError::InsufficientPermissionError); + } + + for board in form.boards { + if let Some(board) = Board::read(&ctx, board).await? { + board.delete(&ctx).await?; + } + } + + let res = HttpResponse::SeeOther() + .append_header(("Location", "/staff/boards")) + .finish(); + + Ok(res) +} diff --git a/src/web/staff/actions/remove_news.rs b/src/web/staff/actions/remove_news.rs new file mode 100644 index 0000000..f0aedef --- /dev/null +++ b/src/web/staff/actions/remove_news.rs @@ -0,0 +1,65 @@ +use std::fmt::Write; + +use actix_web::{post, web::Data, HttpRequest, HttpResponse}; +use serde::Deserialize; + +use crate::{ + ctx::Ctx, + db::models::NewsPost, + error::NekrochanError, + qsform::QsForm, + web::{actions::ActionTemplate, template_response, TemplateCtx}, +}; + +#[derive(Deserialize)] +pub struct RemoveNewsForm { + #[serde(default)] + pub news: Vec, +} + +#[post("/staff/actions/remove-news")] +pub async fn remove_news( + ctx: Data, + req: HttpRequest, + QsForm(form): QsForm, +) -> Result { + let tcx = TemplateCtx::new(&ctx, &req).await?; + + if !(tcx.perms.owner() || tcx.perms.news()) { + return Err(NekrochanError::InsufficientPermissionError); + } + + let mut news = Vec::new(); + + for id in form.news { + if let Some(newspost) = NewsPost::read(&ctx, id).await? { + news.push(newspost); + } + } + + let mut response = String::new(); + let mut news_removed = 0; + + for newspost in news { + if !tcx.perms.owner() && tcx.account != Some(newspost.author.clone()) { + writeln!( + &mut response, + "[Chyba] pouze vlastník nebo autor může odstranit novinky." + ) + .ok(); + + continue; + } + + newspost.delete(&ctx).await?; + news_removed += 1; + } + + if news_removed != 0 { + writeln!(&mut response, "[Úspěch] Odstraněny novinky: {news_removed}").ok(); + } + + let template = ActionTemplate { tcx, response }; + + template_response(&template) +} diff --git a/src/web/staff/actions/transfer_ownership.rs b/src/web/staff/actions/transfer_ownership.rs new file mode 100755 index 0000000..bbd3103 --- /dev/null +++ b/src/web/staff/actions/transfer_ownership.rs @@ -0,0 +1,39 @@ +use actix_web::{post, web::Data, HttpRequest, HttpResponse}; +use serde::Deserialize; + +use crate::{ + ctx::Ctx, db::models::Account, error::NekrochanError, qsform::QsForm, + web::tcx::account_from_auth, +}; + +#[derive(Deserialize)] +pub struct TransferOwnershipForm { + account: String, +} + +#[post("/staff/actions/transfer-ownership")] +pub async fn transfer_ownership( + ctx: Data, + req: HttpRequest, + QsForm(form): QsForm, +) -> Result { + let old_owner = account_from_auth(&ctx, &req).await?; + + if !old_owner.perms().owner() { + return Err(NekrochanError::InsufficientPermissionError); + } + + let new_owner = form.account; + let new_owner = Account::read(&ctx, new_owner.clone()) + .await? + .ok_or(NekrochanError::AccountNotFound(new_owner))?; + + old_owner.update_owner(&ctx, false).await?; + new_owner.update_owner(&ctx, true).await?; + + let res = HttpResponse::SeeOther() + .append_header(("Location", "/staff/account")) + .finish(); + + Ok(res) +} diff --git a/src/web/staff/actions/update_board_config.rs b/src/web/staff/actions/update_board_config.rs new file mode 100755 index 0000000..bce3864 --- /dev/null +++ b/src/web/staff/actions/update_board_config.rs @@ -0,0 +1,105 @@ +use actix_web::{post, web::Data, HttpRequest, HttpResponse}; +use serde::Deserialize; + +use crate::{ + cfg::BoardCfg, ctx::Ctx, db::models::Board, error::NekrochanError, qsform::QsForm, + web::tcx::account_from_auth, +}; + +#[derive(Deserialize)] +pub struct UpdateBoardConfigForm { + board: String, + anon_name: String, + page_size: i64, + page_count: i64, + file_limit: usize, + bump_limit: i32, + reply_limit: i32, + locked: Option, + user_ids: Option, + flags: Option, + thread_captcha: String, + reply_captcha: String, + board_theme: String, + require_thread_content: Option, + require_thread_file: Option, + require_reply_content: Option, + require_reply_file: Option, + antispam: Option, + antispam_ip: i64, + antispam_content: i64, + antispam_both: i64, + thread_cooldown: i64, +} + +#[post("/staff/actions/update-board-config")] +pub async fn update_board_config( + ctx: Data, + req: HttpRequest, + QsForm(form): QsForm, +) -> Result { + let account = account_from_auth(&ctx, &req).await?; + + if !(account.perms().owner() || account.perms().board_config()) { + return Err(NekrochanError::InsufficientPermissionError); + } + + let board = form.board; + let board = Board::read(&ctx, board.clone()) + .await? + .ok_or(NekrochanError::BoardNotFound(board))?; + + let anon_name = form.anon_name; + let page_size = form.page_size; + let page_count = form.page_count; + let file_limit = form.file_limit; + let bump_limit = form.bump_limit; + let reply_limit = form.reply_limit; + let locked = form.locked.is_some(); + let user_ids = form.user_ids.is_some(); + let flags = form.flags.is_some(); + let thread_captcha = form.thread_captcha; + let reply_captcha = form.reply_captcha; + let board_theme = form.board_theme; + let require_thread_content = form.require_thread_content.is_some(); + let require_thread_file = form.require_thread_file.is_some(); + let require_reply_content = form.require_reply_content.is_some(); + let require_reply_file = form.require_reply_file.is_some(); + let antispam = form.antispam.is_some(); + let antispam_ip = form.antispam_ip; + let antispam_content = form.antispam_content; + let antispam_both = form.antispam_both; + let thread_cooldown = form.thread_cooldown; + + let config = BoardCfg { + anon_name, + page_size, + page_count, + file_limit, + bump_limit, + reply_limit, + locked, + user_ids, + flags, + thread_captcha, + reply_captcha, + board_theme, + require_thread_content, + require_thread_file, + require_reply_content, + require_reply_file, + antispam, + antispam_ip, + antispam_content, + antispam_both, + thread_cooldown, + }; + + board.update_config(&ctx, config).await?; + + let res = HttpResponse::SeeOther() + .append_header(("Location", "/staff/boards")) + .finish(); + + Ok(res) +} diff --git a/src/web/staff/actions/update_boards.rs b/src/web/staff/actions/update_boards.rs new file mode 100755 index 0000000..77f6b92 --- /dev/null +++ b/src/web/staff/actions/update_boards.rs @@ -0,0 +1,57 @@ +use actix_web::{ + post, + web::{Bytes, Data}, + HttpRequest, HttpResponse, +}; +use serde::Deserialize; +use serde_qs::Config; + +use crate::{ctx::Ctx, db::models::Board, error::NekrochanError, web::tcx::account_from_auth}; + +#[derive(Deserialize)] +pub struct UpdateBoardsForm { + #[serde(default)] + boards: Vec, + name: String, + description: String, +} + +#[post("/staff/actions/update-boards")] +pub async fn update_boards( + ctx: Data, + req: HttpRequest, + bytes: Bytes, +) -> Result { + let account = account_from_auth(&ctx, &req).await?; + + if !account.perms().owner() { + return Err(NekrochanError::InsufficientPermissionError); + } + + let config = Config::new(10, false); + let form: UpdateBoardsForm = config.deserialize_bytes(&bytes)?; + + let name = form.name.trim().to_owned(); + let description = form.description.trim().to_owned(); + + if name.is_empty() || name.len() > 32 { + return Err(NekrochanError::BoardNameFormatError); + } + + if description.len() > 128 { + return Err(NekrochanError::DescriptionFormatError); + } + + for board in form.boards { + if let Some(board) = Board::read(&ctx, board).await? { + board.update_name(&ctx, name.clone()).await?; + board.update_description(&ctx, description.clone()).await?; + } + } + + let res = HttpResponse::SeeOther() + .append_header(("Location", "/staff/boards")) + .finish(); + + Ok(res) +} diff --git a/src/web/staff/actions/update_permissions.rs b/src/web/staff/actions/update_permissions.rs new file mode 100755 index 0000000..c20f32d --- /dev/null +++ b/src/web/staff/actions/update_permissions.rs @@ -0,0 +1,128 @@ +use actix_web::{post, web::Data, HttpRequest, HttpResponse}; +use enumflags2::BitFlags; +use serde::Deserialize; + +use crate::{ + ctx::Ctx, db::models::Account, error::NekrochanError, perms::Permissions, qsform::QsForm, + web::tcx::account_from_auth, +}; + +#[derive(Deserialize)] +pub struct UpdatePermissionsForm { + account: String, + edit_posts: Option, + manage_posts: Option, + capcodes: Option, + custom_capcodes: Option, + staff_log: Option, + reports: Option, + bans: Option, + banners: Option, + board_config: Option, + news: Option, + jannytext: Option, + view_ips: Option, + bypass_bans: Option, + bypass_board_lock: Option, + bypass_thread_lock: Option, + bypass_captcha: Option, + bypass_antispam: Option, +} + +#[post("/staff/actions/update-permissions")] +pub async fn update_permissions( + ctx: Data, + req: HttpRequest, + QsForm(form): QsForm, +) -> Result { + let account = account_from_auth(&ctx, &req).await?; + + if !account.perms().owner() { + return Err(NekrochanError::InsufficientPermissionError); + } + + let updated_account = form.account; + let updated_account = Account::read(&ctx, updated_account.clone()) + .await? + .ok_or(NekrochanError::AccountNotFound(updated_account))?; + + let mut permissions = BitFlags::empty(); + + if form.edit_posts.is_some() { + permissions |= Permissions::EditPosts; + } + + if form.manage_posts.is_some() { + permissions |= Permissions::ManagePosts; + } + + if form.capcodes.is_some() { + permissions |= Permissions::Capcodes; + } + + if form.custom_capcodes.is_some() { + permissions |= Permissions::CustomCapcodes; + } + + if form.staff_log.is_some() { + permissions |= Permissions::StaffLog; + } + + if form.reports.is_some() { + permissions |= Permissions::Reports; + } + + if form.bans.is_some() { + permissions |= Permissions::Bans; + } + + if form.banners.is_some() { + permissions |= Permissions::BoardBanners; + } + + if form.board_config.is_some() { + permissions |= Permissions::BoardConfig; + } + + if form.news.is_some() { + permissions |= Permissions::News; + } + + if form.jannytext.is_some() { + permissions |= Permissions::Jannytext; + } + + if form.view_ips.is_some() { + permissions |= Permissions::ViewIPs; + } + + if form.bypass_bans.is_some() { + permissions |= Permissions::BypassBans; + } + + if form.bypass_board_lock.is_some() { + permissions |= Permissions::BypassBoardLock; + } + + if form.bypass_thread_lock.is_some() { + permissions |= Permissions::BypassThreadLock; + } + + if form.bypass_captcha.is_some() { + permissions |= Permissions::BypassCaptcha; + } + + if form.bypass_antispam.is_some() { + permissions |= Permissions::BypassAntispam; + } + + updated_account + .update_permissions(&ctx, permissions.bits()) + .await?; + + let res = HttpResponse::SeeOther() + .append_header(("Location", "/staff/accounts")) + .finish(); + + Ok(res) +} diff --git a/src/web/staff/banners.rs b/src/web/staff/banners.rs new file mode 100755 index 0000000..1b651d7 --- /dev/null +++ b/src/web/staff/banners.rs @@ -0,0 +1,30 @@ +use actix_web::{get, web::Data, HttpRequest, HttpResponse}; +use askama::Template; + +use crate::{ + ctx::Ctx, + db::models::Banner, + error::NekrochanError, + web::{tcx::TemplateCtx, template_response}, +}; + +#[derive(Template)] +#[template(path = "staff/banners.html")] +struct BannersTemplate { + tcx: TemplateCtx, + banners: Vec, +} + +#[get("/staff/banners")] +pub async fn banners(ctx: Data, req: HttpRequest) -> Result { + let tcx = TemplateCtx::new(&ctx, &req).await?; + + if !(tcx.perms.owner() || tcx.perms.banners()) { + return Err(NekrochanError::InsufficientPermissionError); + } + + let banners = Banner::read_all(&ctx).await?; + let template = BannersTemplate { tcx, banners }; + + template_response(&template) +} diff --git a/src/web/staff/bans.rs b/src/web/staff/bans.rs new file mode 100755 index 0000000..70d1efd --- /dev/null +++ b/src/web/staff/bans.rs @@ -0,0 +1,31 @@ +use actix_web::{get, web::Data, HttpRequest, HttpResponse}; +use askama::Template; + +use crate::{ + ctx::Ctx, + db::models::Ban, + error::NekrochanError, + filters, + web::{tcx::TemplateCtx, template_response}, +}; + +#[derive(Template)] +#[template(path = "staff/bans.html")] +struct BansTemplate { + tcx: TemplateCtx, + bans: Vec, +} + +#[get("/staff/bans")] +pub async fn bans(ctx: Data, req: HttpRequest) -> Result { + let tcx = TemplateCtx::new(&ctx, &req).await?; + + if !(tcx.perms.owner() || tcx.perms.bans()) { + return Err(NekrochanError::InsufficientPermissionError); + } + + let bans = Ban::read_all(&ctx).await?; + let template = BansTemplate { tcx, bans }; + + template_response(&template) +} diff --git a/src/web/staff/board_config.rs b/src/web/staff/board_config.rs new file mode 100755 index 0000000..6da2dd6 --- /dev/null +++ b/src/web/staff/board_config.rs @@ -0,0 +1,42 @@ +use actix_web::{ + get, + web::{Data, Path}, + HttpRequest, HttpResponse, +}; +use askama::Template; + +use crate::{ + ctx::Ctx, + db::models::Board, + error::NekrochanError, + web::{tcx::TemplateCtx, template_response}, +}; + +#[derive(Template)] +#[template(path = "staff/board-config.html")] +struct BannersTemplate { + tcx: TemplateCtx, + board: Board, +} + +#[get("/staff/board-config/{board}")] +pub async fn board_config( + ctx: Data, + req: HttpRequest, + board: Path, +) -> Result { + let tcx = TemplateCtx::new(&ctx, &req).await?; + + if !(tcx.perms.owner() || tcx.perms.board_config()) { + return Err(NekrochanError::InsufficientPermissionError); + } + + let board = board.into_inner(); + let board = Board::read(&ctx, board.clone()) + .await? + .ok_or(NekrochanError::BoardNotFound(board))?; + + let template = BannersTemplate { tcx, board }; + + template_response(&template) +} diff --git a/src/web/staff/boards.rs b/src/web/staff/boards.rs new file mode 100755 index 0000000..bd3b1ff --- /dev/null +++ b/src/web/staff/boards.rs @@ -0,0 +1,31 @@ +use actix_web::{get, web::Data, HttpRequest, HttpResponse}; +use askama::Template; + +use crate::{ + ctx::Ctx, + db::models::Board, + error::NekrochanError, + filters, + web::{tcx::TemplateCtx, template_response}, +}; + +#[derive(Template)] +#[template(path = "staff/boards.html")] +struct BoardsTemplate { + tcx: TemplateCtx, + boards: Vec, +} + +#[get("/staff/boards")] +pub async fn boards(ctx: Data, req: HttpRequest) -> Result { + let tcx = TemplateCtx::new(&ctx, &req).await?; + + if !(tcx.perms.owner() || tcx.perms.board_config() || tcx.perms.banners()) { + return Err(NekrochanError::InsufficientPermissionError); + } + + let boards = Board::read_all(&ctx).await?; + let template = BoardsTemplate { tcx, boards }; + + template_response(&template) +} diff --git a/src/web/staff/edit_news.rs b/src/web/staff/edit_news.rs new file mode 100644 index 0000000..d7f9865 --- /dev/null +++ b/src/web/staff/edit_news.rs @@ -0,0 +1,49 @@ +use actix_web::{post, web::Data, HttpRequest, HttpResponse}; +use askama::Template; +use serde::Deserialize; + +use crate::{ + ctx::Ctx, + db::models::NewsPost, + error::NekrochanError, + filters, + qsform::QsForm, + web::{template_response, TemplateCtx}, +}; + +#[derive(Deserialize)] +pub struct EditNewsForm { + pub news: Vec, +} + +#[derive(Template)] +#[template(path = "staff/edit-news.html")] +struct EditNewsTemplate { + tcx: TemplateCtx, + news: Vec, +} + +#[post("/staff/edit-news")] +pub async fn edit_news( + ctx: Data, + req: HttpRequest, + QsForm(form): QsForm, +) -> Result { + let tcx = TemplateCtx::new(&ctx, &req).await?; + + if !(tcx.perms.owner() || tcx.perms.news()) { + return Err(NekrochanError::InsufficientPermissionError); + } + + let mut news = Vec::new(); + + for id in form.news { + if let Some(newspost) = NewsPost::read(&ctx, id).await? { + news.push(newspost); + } + } + + let template = EditNewsTemplate { tcx, news }; + + template_response(&template) +} diff --git a/src/web/staff/mod.rs b/src/web/staff/mod.rs new file mode 100755 index 0000000..f61de2e --- /dev/null +++ b/src/web/staff/mod.rs @@ -0,0 +1,11 @@ +pub mod account; +pub mod accounts; +pub mod actions; +pub mod banners; +pub mod bans; +pub mod board_config; +pub mod boards; +pub mod edit_news; +pub mod news; +pub mod permissions; +pub mod reports; diff --git a/src/web/staff/news.rs b/src/web/staff/news.rs new file mode 100644 index 0000000..7bf1a61 --- /dev/null +++ b/src/web/staff/news.rs @@ -0,0 +1,31 @@ +use actix_web::{get, web::Data, HttpRequest, HttpResponse}; +use askama::Template; + +use crate::{ + ctx::Ctx, + db::models::NewsPost, + error::NekrochanError, + filters, + web::{tcx::TemplateCtx, template_response}, +}; + +#[derive(Template)] +#[template(path = "staff/news.html")] +struct NewsTemplate { + tcx: TemplateCtx, + news: Vec, +} + +#[get("/staff/news")] +pub async fn news(ctx: Data, req: HttpRequest) -> Result { + let tcx = TemplateCtx::new(&ctx, &req).await?; + + if !(tcx.perms.owner() || tcx.perms.news()) { + return Err(NekrochanError::InsufficientPermissionError); + } + + let news = NewsPost::read_all(&ctx).await?; + let template = NewsTemplate { tcx, news }; + + template_response(&template) +} diff --git a/src/web/staff/permissions.rs b/src/web/staff/permissions.rs new file mode 100755 index 0000000..251ed33 --- /dev/null +++ b/src/web/staff/permissions.rs @@ -0,0 +1,42 @@ +use actix_web::{ + get, + web::{Data, Path}, + HttpRequest, HttpResponse, +}; +use askama::Template; + +use crate::{ + ctx::Ctx, + db::models::Account, + error::NekrochanError, + web::{tcx::TemplateCtx, template_response}, +}; + +#[derive(Template)] +#[template(path = "staff/permissions.html")] +struct PermissionsTemplate { + tcx: TemplateCtx, + account: Account, +} + +#[get("/staff/permissions/{account}")] +pub async fn permissions( + ctx: Data, + req: HttpRequest, + path: Path, +) -> Result { + let tcx = TemplateCtx::new(&ctx, &req).await?; + + if !tcx.perms.owner() { + return Err(NekrochanError::InsufficientPermissionError); + } + + let account = path.into_inner(); + let account = Account::read(&ctx, account.clone()) + .await? + .ok_or(NekrochanError::AccountNotFound(account))?; + + let template = PermissionsTemplate { tcx, account }; + + template_response(&template) +} diff --git a/src/web/staff/reports.rs b/src/web/staff/reports.rs new file mode 100755 index 0000000..3e33249 --- /dev/null +++ b/src/web/staff/reports.rs @@ -0,0 +1,63 @@ +use std::collections::HashMap; + +use actix_web::{ + get, + web::{Data, Query}, + HttpRequest, HttpResponse, +}; +use askama::Template; +use serde::Deserialize; + +use crate::{ + ctx::Ctx, + db::models::{Board, Post}, + error::NekrochanError, + filters, + web::{tcx::TemplateCtx, template_response}, +}; + +#[derive(Deserialize)] +pub struct BoardQuery { + page: i64, +} + +#[allow(dead_code)] +#[derive(Template)] +#[template(path = "staff/reports.html")] +struct ReportsTemplate { + tcx: TemplateCtx, + boards: HashMap, + posts: Vec, + page: i64, +} + +#[get("/staff/reports")] +async fn reports( + ctx: Data, + req: HttpRequest, + query: Option>, +) -> Result { + let tcx = TemplateCtx::new(&ctx, &req).await?; + + if !(tcx.perms.owner() || tcx.perms.reports()) { + return Err(NekrochanError::InsufficientPermissionError); + } + + let boards = Board::read_all_map(&ctx).await?; + let page = query.map_or(1, |q| q.page); + + if page <= 0 { + return Err(NekrochanError::InvalidPageError); + } + + let posts = Post::read_reports_page(&ctx, page).await?; + + let template = ReportsTemplate { + tcx, + boards, + posts, + page, + }; + + template_response(&template) +} diff --git a/src/web/tcx.rs b/src/web/tcx.rs new file mode 100755 index 0000000..e721af0 --- /dev/null +++ b/src/web/tcx.rs @@ -0,0 +1,116 @@ +use actix_web::HttpRequest; +use redis::{AsyncCommands, Commands, Connection}; +use sqlx::query_as; +use std::{ + collections::HashSet, + net::{IpAddr, Ipv4Addr}, +}; + +use crate::{ + auth::Claims, cfg::Cfg, ctx::Ctx, db::models::Account, error::NekrochanError, + perms::PermissionWrapper, +}; + +#[derive(Debug, Clone)] +pub struct TemplateCtx { + pub cfg: Cfg, + pub boards: Vec, + pub account: Option, + pub perms: PermissionWrapper, + pub ip: IpAddr, + pub yous: HashSet, + pub report_count: Option, +} + +impl TemplateCtx { + pub async fn new(ctx: &Ctx, req: &HttpRequest) -> Result { + let cfg = ctx.cfg.clone(); + let boards = ctx.cache().lrange("board_ids", 0, -1).await?; + + let account = account_from_auth_opt(ctx, req).await?; + + let perms = match &account { + Some(account) => account.perms(), + None => PermissionWrapper::new(0, false), + }; + + let (ip, _) = ip_from_req(req)?; + let yous = ctx.cache().zrange(format!("by_ip:{ip}"), 0, -1).await?; + + let account = account.map(|account| account.username); + + let report_count = if perms.owner() || perms.reports() { + let count: Option> = query_as("SELECT SUM(jsonb_array_length(reports)) FROM overboard WHERE reports != '[]'::jsonb") + .fetch_optional(ctx.db()) + .await + .ok(); + + match count { + Some(Some((count,))) if count != 0 => Some(count), + _ => None, + } + } else { + None + }; + + let tcx = Self { + cfg, + boards, + perms, + ip, + yous, + account, + report_count, + }; + + Ok(tcx) + } + + pub fn update_yous(&mut self, cache: &mut Connection) -> Result<(), NekrochanError> { + self.yous = cache.zrange(format!("by_ip:{}", self.ip), 0, -1)?; + Ok(()) + } +} + +pub async fn account_from_auth(ctx: &Ctx, req: &HttpRequest) -> Result { + let account = account_from_auth_opt(ctx, req) + .await? + .ok_or(NekrochanError::NotLoggedInError)?; + + Ok(account) +} + +pub async fn account_from_auth_opt( + ctx: &Ctx, + req: &HttpRequest, +) -> Result, NekrochanError> { + let account = match req.cookie("auth") { + Some(auth) => { + let claims = Claims::decode(ctx, auth.value())?; + let account = Account::read(ctx, claims.sub) + .await? + .ok_or(NekrochanError::InvalidAuthError)?; + + Some(account) + } + None => None, + }; + + Ok(account) +} + +pub fn ip_from_req(req: &HttpRequest) -> Result<(IpAddr, String), NekrochanError> { + let ip = req + .connection_info() + .realip_remote_addr() + .map_or(IpAddr::V4(Ipv4Addr::UNSPECIFIED), |ip| { + ip.parse().unwrap_or(IpAddr::V4(Ipv4Addr::UNSPECIFIED)) + }); + + let country = req.headers().get("X-Country-Code").map_or_else( + || "xx".into(), + |hdr| hdr.to_str().unwrap_or("xx").to_ascii_lowercase(), + ); + + Ok((ip, country)) +} diff --git a/src/web/thread.rs b/src/web/thread.rs new file mode 100644 index 0000000..57a12a7 --- /dev/null +++ b/src/web/thread.rs @@ -0,0 +1,59 @@ +use actix_web::{ + get, + http::StatusCode, + web::{Data, Path}, + HttpRequest, HttpResponse, +}; +use askama::Template; + +use crate::{ + ctx::Ctx, + db::models::{Board, Post}, + error::NekrochanError, + filters, + web::{tcx::TemplateCtx, template_response}, +}; + +#[derive(Template)] +#[template(path = "thread.html")] +struct ThreadTemplate { + tcx: TemplateCtx, + board: Board, + thread: Post, + replies: Vec, +} + +#[get("/boards/{board}/{thread}")] +pub async fn thread( + ctx: Data, + req: HttpRequest, + path: Path<(String, i64)>, +) -> Result { + let tcx = TemplateCtx::new(&ctx, &req).await?; + + let (board, id) = path.into_inner(); + let board = Board::read(&ctx, board.clone()) + .await? + .ok_or(NekrochanError::BoardNotFound(board))?; + + let thread = Post::read(&ctx, board.id.clone(), id) + .await? + .ok_or(NekrochanError::PostNotFound(board.id.clone(), id))?; + + if thread.thread.is_some() { + return Ok(HttpResponse::build(StatusCode::PERMANENT_REDIRECT) + .append_header(("Location", thread.post_url())) + .finish()); + } + + let replies = thread.read_replies(&ctx).await?; + + let template = ThreadTemplate { + tcx, + board, + thread, + replies, + }; + + template_response(&template) +} diff --git a/src/web/thread_json.rs b/src/web/thread_json.rs new file mode 100644 index 0000000..277022f --- /dev/null +++ b/src/web/thread_json.rs @@ -0,0 +1,58 @@ +use actix_web::{ + get, + web::{Data, Json, Path}, + HttpRequest, +}; +use askama::Template; +use std::{collections::HashMap, vec}; + +use crate::{ + ctx::Ctx, + db::models::{Board, Post}, + error::NekrochanError, + web::tcx::TemplateCtx, + PostTemplate, +}; + +#[get("/thread-json/{board}/{id}")] +pub async fn thread_json( + ctx: Data, + req: HttpRequest, + path: Path<(String, i64)>, +) -> Result>, NekrochanError> { + let (board, id) = path.into_inner(); + + let tcx = TemplateCtx::new(&ctx, &req).await?; + + let board = Board::read(&ctx, board.clone()) + .await? + .ok_or(NekrochanError::BoardNotFound(board))?; + + let thread = Post::read(&ctx, board.id.clone(), id) + .await? + .ok_or(NekrochanError::PostNotFound(board.id.clone(), id))?; + + if thread.thread.is_some() { + return Err(NekrochanError::IsReplyError); + } + + let mut res = HashMap::new(); + + let replies = thread.read_replies(&ctx).await?; + let posts = [vec![thread], replies].concat(); + + for post in posts { + let id = post.id; + let tcx = &tcx; + let board = &board; + let post = &post; + + let html = PostTemplate { tcx, board, post } + .render() + .unwrap_or_default(); + + res.insert(id, html); + } + + Ok(Json(res)) +} diff --git a/static/default-banner.png b/static/default-banner.png new file mode 100644 index 0000000..e5686d5 Binary files /dev/null and b/static/default-banner.png differ diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100644 index 0000000..6585a85 Binary files /dev/null and b/static/favicon.ico differ diff --git a/static/flags/ad.png b/static/flags/ad.png new file mode 100755 index 0000000..625ca84 Binary files /dev/null and b/static/flags/ad.png differ diff --git a/static/flags/ae.png b/static/flags/ae.png new file mode 100755 index 0000000..ef3a1ec Binary files /dev/null and b/static/flags/ae.png differ diff --git a/static/flags/af.png b/static/flags/af.png new file mode 100755 index 0000000..a4742e2 Binary files /dev/null and b/static/flags/af.png differ diff --git a/static/flags/ag.png b/static/flags/ag.png new file mode 100755 index 0000000..556d550 Binary files /dev/null and b/static/flags/ag.png differ diff --git a/static/flags/ai.png b/static/flags/ai.png new file mode 100755 index 0000000..74ed29d Binary files /dev/null and b/static/flags/ai.png differ diff --git a/static/flags/al.png b/static/flags/al.png new file mode 100755 index 0000000..92354cb Binary files /dev/null and b/static/flags/al.png differ diff --git a/static/flags/am.png b/static/flags/am.png new file mode 100755 index 0000000..344a2a8 Binary files /dev/null and b/static/flags/am.png differ diff --git a/static/flags/an.png b/static/flags/an.png new file mode 100755 index 0000000..633e4b8 Binary files /dev/null and b/static/flags/an.png differ diff --git a/static/flags/ao.png b/static/flags/ao.png new file mode 100755 index 0000000..bcbd1d6 Binary files /dev/null and b/static/flags/ao.png differ diff --git a/static/flags/ar.png b/static/flags/ar.png new file mode 100755 index 0000000..e5ef8f1 Binary files /dev/null and b/static/flags/ar.png differ diff --git a/static/flags/as.png b/static/flags/as.png new file mode 100755 index 0000000..32f30e4 Binary files /dev/null and b/static/flags/as.png differ diff --git a/static/flags/at.png b/static/flags/at.png new file mode 100755 index 0000000..0f15f34 Binary files /dev/null and b/static/flags/at.png differ diff --git a/static/flags/au.png b/static/flags/au.png new file mode 100755 index 0000000..a01389a Binary files /dev/null and b/static/flags/au.png differ diff --git a/static/flags/aw.png b/static/flags/aw.png new file mode 100755 index 0000000..a3579c2 Binary files /dev/null and b/static/flags/aw.png differ diff --git a/static/flags/ax.png b/static/flags/ax.png new file mode 100755 index 0000000..1eea80a Binary files /dev/null and b/static/flags/ax.png differ diff --git a/static/flags/az.png b/static/flags/az.png new file mode 100755 index 0000000..4ee9fe5 Binary files /dev/null and b/static/flags/az.png differ diff --git a/static/flags/ba.png b/static/flags/ba.png new file mode 100755 index 0000000..c774992 Binary files /dev/null and b/static/flags/ba.png differ diff --git a/static/flags/bb.png b/static/flags/bb.png new file mode 100755 index 0000000..0df19c7 Binary files /dev/null and b/static/flags/bb.png differ diff --git a/static/flags/bd.png b/static/flags/bd.png new file mode 100755 index 0000000..076a8bf Binary files /dev/null and b/static/flags/bd.png differ diff --git a/static/flags/be.png b/static/flags/be.png new file mode 100755 index 0000000..d86ebc8 Binary files /dev/null and b/static/flags/be.png differ diff --git a/static/flags/bf.png b/static/flags/bf.png new file mode 100755 index 0000000..ab5ce8f Binary files /dev/null and b/static/flags/bf.png differ diff --git a/static/flags/bg.png b/static/flags/bg.png new file mode 100755 index 0000000..0469f06 Binary files /dev/null and b/static/flags/bg.png differ diff --git a/static/flags/bh.png b/static/flags/bh.png new file mode 100755 index 0000000..ea8ce68 Binary files /dev/null and b/static/flags/bh.png differ diff --git a/static/flags/bi.png b/static/flags/bi.png new file mode 100755 index 0000000..5cc2e30 Binary files /dev/null and b/static/flags/bi.png differ diff --git a/static/flags/bj.png b/static/flags/bj.png new file mode 100755 index 0000000..1cc8b45 Binary files /dev/null and b/static/flags/bj.png differ diff --git a/static/flags/bm.png b/static/flags/bm.png new file mode 100755 index 0000000..c0c7aea Binary files /dev/null and b/static/flags/bm.png differ diff --git a/static/flags/bn.png b/static/flags/bn.png new file mode 100755 index 0000000..8fb0984 Binary files /dev/null and b/static/flags/bn.png differ diff --git a/static/flags/bo.png b/static/flags/bo.png new file mode 100755 index 0000000..ce7ba52 Binary files /dev/null and b/static/flags/bo.png differ diff --git a/static/flags/br.png b/static/flags/br.png new file mode 100755 index 0000000..9b1a553 Binary files /dev/null and b/static/flags/br.png differ diff --git a/static/flags/bs.png b/static/flags/bs.png new file mode 100755 index 0000000..639fa6c Binary files /dev/null and b/static/flags/bs.png differ diff --git a/static/flags/bt.png b/static/flags/bt.png new file mode 100755 index 0000000..1d512df Binary files /dev/null and b/static/flags/bt.png differ diff --git a/static/flags/bv.png b/static/flags/bv.png new file mode 100755 index 0000000..160b6b5 Binary files /dev/null and b/static/flags/bv.png differ diff --git a/static/flags/bw.png b/static/flags/bw.png new file mode 100755 index 0000000..fcb1039 Binary files /dev/null and b/static/flags/bw.png differ diff --git a/static/flags/by.png b/static/flags/by.png new file mode 100755 index 0000000..504774e Binary files /dev/null and b/static/flags/by.png differ diff --git a/static/flags/bz.png b/static/flags/bz.png new file mode 100755 index 0000000..be63ee1 Binary files /dev/null and b/static/flags/bz.png differ diff --git a/static/flags/ca.png b/static/flags/ca.png new file mode 100755 index 0000000..1f20419 Binary files /dev/null and b/static/flags/ca.png differ diff --git a/static/flags/cc.png b/static/flags/cc.png new file mode 100755 index 0000000..aed3d3b Binary files /dev/null and b/static/flags/cc.png differ diff --git a/static/flags/cd.png b/static/flags/cd.png new file mode 100755 index 0000000..5e48942 Binary files /dev/null and b/static/flags/cd.png differ diff --git a/static/flags/cf.png b/static/flags/cf.png new file mode 100755 index 0000000..da687bd Binary files /dev/null and b/static/flags/cf.png differ diff --git a/static/flags/cg.png b/static/flags/cg.png new file mode 100755 index 0000000..a859792 Binary files /dev/null and b/static/flags/cg.png differ diff --git a/static/flags/ch.png b/static/flags/ch.png new file mode 100755 index 0000000..242ec01 Binary files /dev/null and b/static/flags/ch.png differ diff --git a/static/flags/ci.png b/static/flags/ci.png new file mode 100755 index 0000000..3f2c62e Binary files /dev/null and b/static/flags/ci.png differ diff --git a/static/flags/ck.png b/static/flags/ck.png new file mode 100755 index 0000000..746d3d6 Binary files /dev/null and b/static/flags/ck.png differ diff --git a/static/flags/cl.png b/static/flags/cl.png new file mode 100755 index 0000000..29c6d61 Binary files /dev/null and b/static/flags/cl.png differ diff --git a/static/flags/cm.png b/static/flags/cm.png new file mode 100755 index 0000000..f65c5bd Binary files /dev/null and b/static/flags/cm.png differ diff --git a/static/flags/cn.png b/static/flags/cn.png new file mode 100755 index 0000000..8914414 Binary files /dev/null and b/static/flags/cn.png differ diff --git a/static/flags/co.png b/static/flags/co.png new file mode 100755 index 0000000..a118ff4 Binary files /dev/null and b/static/flags/co.png differ diff --git a/static/flags/cr.png b/static/flags/cr.png new file mode 100755 index 0000000..c7a3731 Binary files /dev/null and b/static/flags/cr.png differ diff --git a/static/flags/cs.png b/static/flags/cs.png new file mode 100755 index 0000000..8254790 Binary files /dev/null and b/static/flags/cs.png differ diff --git a/static/flags/cu.png b/static/flags/cu.png new file mode 100755 index 0000000..083f1d6 Binary files /dev/null and b/static/flags/cu.png differ diff --git a/static/flags/cv.png b/static/flags/cv.png new file mode 100755 index 0000000..a63f7ea Binary files /dev/null and b/static/flags/cv.png differ diff --git a/static/flags/cx.png b/static/flags/cx.png new file mode 100755 index 0000000..48e31ad Binary files /dev/null and b/static/flags/cx.png differ diff --git a/static/flags/cy.png b/static/flags/cy.png new file mode 100755 index 0000000..5b1ad6c Binary files /dev/null and b/static/flags/cy.png differ diff --git a/static/flags/cz.png b/static/flags/cz.png new file mode 100755 index 0000000..c8403dd Binary files /dev/null and b/static/flags/cz.png differ diff --git a/static/flags/de.png b/static/flags/de.png new file mode 100755 index 0000000..ac4a977 Binary files /dev/null and b/static/flags/de.png differ diff --git a/static/flags/dj.png b/static/flags/dj.png new file mode 100755 index 0000000..582af36 Binary files /dev/null and b/static/flags/dj.png differ diff --git a/static/flags/dk.png b/static/flags/dk.png new file mode 100755 index 0000000..e2993d3 Binary files /dev/null and b/static/flags/dk.png differ diff --git a/static/flags/dm.png b/static/flags/dm.png new file mode 100755 index 0000000..5fbffcb Binary files /dev/null and b/static/flags/dm.png differ diff --git a/static/flags/do.png b/static/flags/do.png new file mode 100755 index 0000000..5a04932 Binary files /dev/null and b/static/flags/do.png differ diff --git a/static/flags/dz.png b/static/flags/dz.png new file mode 100755 index 0000000..335c239 Binary files /dev/null and b/static/flags/dz.png differ diff --git a/static/flags/ec.png b/static/flags/ec.png new file mode 100755 index 0000000..0caa0b1 Binary files /dev/null and b/static/flags/ec.png differ diff --git a/static/flags/ee.png b/static/flags/ee.png new file mode 100755 index 0000000..0c82efb Binary files /dev/null and b/static/flags/ee.png differ diff --git a/static/flags/eg.png b/static/flags/eg.png new file mode 100755 index 0000000..8a3f7a1 Binary files /dev/null and b/static/flags/eg.png differ diff --git a/static/flags/eh.png b/static/flags/eh.png new file mode 100755 index 0000000..90a1195 Binary files /dev/null and b/static/flags/eh.png differ diff --git a/static/flags/er.png b/static/flags/er.png new file mode 100755 index 0000000..13065ae Binary files /dev/null and b/static/flags/er.png differ diff --git a/static/flags/es.png b/static/flags/es.png new file mode 100755 index 0000000..c2de2d7 Binary files /dev/null and b/static/flags/es.png differ diff --git a/static/flags/et.png b/static/flags/et.png new file mode 100755 index 0000000..2e893fa Binary files /dev/null and b/static/flags/et.png differ diff --git a/static/flags/fam.png b/static/flags/fam.png new file mode 100755 index 0000000..cf50c75 Binary files /dev/null and b/static/flags/fam.png differ diff --git a/static/flags/fi.png b/static/flags/fi.png new file mode 100755 index 0000000..14ec091 Binary files /dev/null and b/static/flags/fi.png differ diff --git a/static/flags/fj.png b/static/flags/fj.png new file mode 100755 index 0000000..cee9988 Binary files /dev/null and b/static/flags/fj.png differ diff --git a/static/flags/fk.png b/static/flags/fk.png new file mode 100755 index 0000000..ceaeb27 Binary files /dev/null and b/static/flags/fk.png differ diff --git a/static/flags/fm.png b/static/flags/fm.png new file mode 100755 index 0000000..066bb24 Binary files /dev/null and b/static/flags/fm.png differ diff --git a/static/flags/fo.png b/static/flags/fo.png new file mode 100755 index 0000000..cbceb80 Binary files /dev/null and b/static/flags/fo.png differ diff --git a/static/flags/fr.png b/static/flags/fr.png new file mode 100755 index 0000000..8332c4e Binary files /dev/null and b/static/flags/fr.png differ diff --git a/static/flags/ga.png b/static/flags/ga.png new file mode 100755 index 0000000..0e0d434 Binary files /dev/null and b/static/flags/ga.png differ diff --git a/static/flags/gb.png b/static/flags/gb.png new file mode 100755 index 0000000..ff701e1 Binary files /dev/null and b/static/flags/gb.png differ diff --git a/static/flags/gd.png b/static/flags/gd.png new file mode 100755 index 0000000..9ab57f5 Binary files /dev/null and b/static/flags/gd.png differ diff --git a/static/flags/ge.png b/static/flags/ge.png new file mode 100755 index 0000000..728d970 Binary files /dev/null and b/static/flags/ge.png differ diff --git a/static/flags/gf.png b/static/flags/gf.png new file mode 100755 index 0000000..8332c4e Binary files /dev/null and b/static/flags/gf.png differ diff --git a/static/flags/gh.png b/static/flags/gh.png new file mode 100755 index 0000000..4e2f896 Binary files /dev/null and b/static/flags/gh.png differ diff --git a/static/flags/gi.png b/static/flags/gi.png new file mode 100755 index 0000000..e76797f Binary files /dev/null and b/static/flags/gi.png differ diff --git a/static/flags/gl.png b/static/flags/gl.png new file mode 100755 index 0000000..ef12a73 Binary files /dev/null and b/static/flags/gl.png differ diff --git a/static/flags/gm.png b/static/flags/gm.png new file mode 100755 index 0000000..0720b66 Binary files /dev/null and b/static/flags/gm.png differ diff --git a/static/flags/gn.png b/static/flags/gn.png new file mode 100755 index 0000000..ea660b0 Binary files /dev/null and b/static/flags/gn.png differ diff --git a/static/flags/gp.png b/static/flags/gp.png new file mode 100755 index 0000000..dbb086d Binary files /dev/null and b/static/flags/gp.png differ diff --git a/static/flags/gq.png b/static/flags/gq.png new file mode 100755 index 0000000..ebe20a2 Binary files /dev/null and b/static/flags/gq.png differ diff --git a/static/flags/gr.png b/static/flags/gr.png new file mode 100755 index 0000000..8651ade Binary files /dev/null and b/static/flags/gr.png differ diff --git a/static/flags/gs.png b/static/flags/gs.png new file mode 100755 index 0000000..7ef0bf5 Binary files /dev/null and b/static/flags/gs.png differ diff --git a/static/flags/gt.png b/static/flags/gt.png new file mode 100755 index 0000000..c43a70d Binary files /dev/null and b/static/flags/gt.png differ diff --git a/static/flags/gu.png b/static/flags/gu.png new file mode 100755 index 0000000..92f37c0 Binary files /dev/null and b/static/flags/gu.png differ diff --git a/static/flags/gw.png b/static/flags/gw.png new file mode 100755 index 0000000..b37bcf0 Binary files /dev/null and b/static/flags/gw.png differ diff --git a/static/flags/gy.png b/static/flags/gy.png new file mode 100755 index 0000000..22cbe2f Binary files /dev/null and b/static/flags/gy.png differ diff --git a/static/flags/hk.png b/static/flags/hk.png new file mode 100755 index 0000000..d5c380c Binary files /dev/null and b/static/flags/hk.png differ diff --git a/static/flags/hm.png b/static/flags/hm.png new file mode 100755 index 0000000..a01389a Binary files /dev/null and b/static/flags/hm.png differ diff --git a/static/flags/hn.png b/static/flags/hn.png new file mode 100755 index 0000000..96f8388 Binary files /dev/null and b/static/flags/hn.png differ diff --git a/static/flags/hr.png b/static/flags/hr.png new file mode 100755 index 0000000..696b515 Binary files /dev/null and b/static/flags/hr.png differ diff --git a/static/flags/ht.png b/static/flags/ht.png new file mode 100755 index 0000000..416052a Binary files /dev/null and b/static/flags/ht.png differ diff --git a/static/flags/hu.png b/static/flags/hu.png new file mode 100755 index 0000000..7baafe4 Binary files /dev/null and b/static/flags/hu.png differ diff --git a/static/flags/id.png b/static/flags/id.png new file mode 100755 index 0000000..c6bc0fa Binary files /dev/null and b/static/flags/id.png differ diff --git a/static/flags/ie.png b/static/flags/ie.png new file mode 100755 index 0000000..26baa31 Binary files /dev/null and b/static/flags/ie.png differ diff --git a/static/flags/il.png b/static/flags/il.png new file mode 100755 index 0000000..2ca772d Binary files /dev/null and b/static/flags/il.png differ diff --git a/static/flags/in.png b/static/flags/in.png new file mode 100755 index 0000000..e4d7e81 Binary files /dev/null and b/static/flags/in.png differ diff --git a/static/flags/io.png b/static/flags/io.png new file mode 100755 index 0000000..3e74b6a Binary files /dev/null and b/static/flags/io.png differ diff --git a/static/flags/iq.png b/static/flags/iq.png new file mode 100755 index 0000000..878a351 Binary files /dev/null and b/static/flags/iq.png differ diff --git a/static/flags/ir.png b/static/flags/ir.png new file mode 100755 index 0000000..c5fd136 Binary files /dev/null and b/static/flags/ir.png differ diff --git a/static/flags/is.png b/static/flags/is.png new file mode 100755 index 0000000..b8f6d0f Binary files /dev/null and b/static/flags/is.png differ diff --git a/static/flags/it.png b/static/flags/it.png new file mode 100755 index 0000000..89692f7 Binary files /dev/null and b/static/flags/it.png differ diff --git a/static/flags/jm.png b/static/flags/jm.png new file mode 100755 index 0000000..7be119e Binary files /dev/null and b/static/flags/jm.png differ diff --git a/static/flags/jo.png b/static/flags/jo.png new file mode 100755 index 0000000..11bd497 Binary files /dev/null and b/static/flags/jo.png differ diff --git a/static/flags/jp.png b/static/flags/jp.png new file mode 100755 index 0000000..325fbad Binary files /dev/null and b/static/flags/jp.png differ diff --git a/static/flags/ke.png b/static/flags/ke.png new file mode 100755 index 0000000..51879ad Binary files /dev/null and b/static/flags/ke.png differ diff --git a/static/flags/kg.png b/static/flags/kg.png new file mode 100755 index 0000000..0a818f6 Binary files /dev/null and b/static/flags/kg.png differ diff --git a/static/flags/kh.png b/static/flags/kh.png new file mode 100755 index 0000000..30f6bb1 Binary files /dev/null and b/static/flags/kh.png differ diff --git a/static/flags/ki.png b/static/flags/ki.png new file mode 100755 index 0000000..2dcce4b Binary files /dev/null and b/static/flags/ki.png differ diff --git a/static/flags/km.png b/static/flags/km.png new file mode 100755 index 0000000..812b2f5 Binary files /dev/null and b/static/flags/km.png differ diff --git a/static/flags/kn.png b/static/flags/kn.png new file mode 100755 index 0000000..febd5b4 Binary files /dev/null and b/static/flags/kn.png differ diff --git a/static/flags/kp.png b/static/flags/kp.png new file mode 100755 index 0000000..d3d509a Binary files /dev/null and b/static/flags/kp.png differ diff --git a/static/flags/kr.png b/static/flags/kr.png new file mode 100755 index 0000000..9c0a78e Binary files /dev/null and b/static/flags/kr.png differ diff --git a/static/flags/kw.png b/static/flags/kw.png new file mode 100755 index 0000000..96546da Binary files /dev/null and b/static/flags/kw.png differ diff --git a/static/flags/ky.png b/static/flags/ky.png new file mode 100755 index 0000000..15c5f8e Binary files /dev/null and b/static/flags/ky.png differ diff --git a/static/flags/kz.png b/static/flags/kz.png new file mode 100755 index 0000000..45a8c88 Binary files /dev/null and b/static/flags/kz.png differ diff --git a/static/flags/la.png b/static/flags/la.png new file mode 100755 index 0000000..e28acd0 Binary files /dev/null and b/static/flags/la.png differ diff --git a/static/flags/lb.png b/static/flags/lb.png new file mode 100755 index 0000000..d0d452b Binary files /dev/null and b/static/flags/lb.png differ diff --git a/static/flags/lc.png b/static/flags/lc.png new file mode 100755 index 0000000..a47d065 Binary files /dev/null and b/static/flags/lc.png differ diff --git a/static/flags/li.png b/static/flags/li.png new file mode 100755 index 0000000..6469909 Binary files /dev/null and b/static/flags/li.png differ diff --git a/static/flags/lk.png b/static/flags/lk.png new file mode 100755 index 0000000..088aad6 Binary files /dev/null and b/static/flags/lk.png differ diff --git a/static/flags/lr.png b/static/flags/lr.png new file mode 100755 index 0000000..89a5bc7 Binary files /dev/null and b/static/flags/lr.png differ diff --git a/static/flags/ls.png b/static/flags/ls.png new file mode 100755 index 0000000..33fdef1 Binary files /dev/null and b/static/flags/ls.png differ diff --git a/static/flags/lt.png b/static/flags/lt.png new file mode 100755 index 0000000..c8ef0da Binary files /dev/null and b/static/flags/lt.png differ diff --git a/static/flags/lu.png b/static/flags/lu.png new file mode 100755 index 0000000..4cabba9 Binary files /dev/null and b/static/flags/lu.png differ diff --git a/static/flags/lv.png b/static/flags/lv.png new file mode 100755 index 0000000..49b6998 Binary files /dev/null and b/static/flags/lv.png differ diff --git a/static/flags/ly.png b/static/flags/ly.png new file mode 100755 index 0000000..b163a9f Binary files /dev/null and b/static/flags/ly.png differ diff --git a/static/flags/ma.png b/static/flags/ma.png new file mode 100755 index 0000000..f386770 Binary files /dev/null and b/static/flags/ma.png differ diff --git a/static/flags/mc.png b/static/flags/mc.png new file mode 100755 index 0000000..1aa830f Binary files /dev/null and b/static/flags/mc.png differ diff --git a/static/flags/md.png b/static/flags/md.png new file mode 100755 index 0000000..4e92c18 Binary files /dev/null and b/static/flags/md.png differ diff --git a/static/flags/me.png b/static/flags/me.png new file mode 100755 index 0000000..ac72535 Binary files /dev/null and b/static/flags/me.png differ diff --git a/static/flags/mg.png b/static/flags/mg.png new file mode 100755 index 0000000..d2715b3 Binary files /dev/null and b/static/flags/mg.png differ diff --git a/static/flags/mh.png b/static/flags/mh.png new file mode 100755 index 0000000..fb523a8 Binary files /dev/null and b/static/flags/mh.png differ diff --git a/static/flags/mk.png b/static/flags/mk.png new file mode 100755 index 0000000..db173aa Binary files /dev/null and b/static/flags/mk.png differ diff --git a/static/flags/ml.png b/static/flags/ml.png new file mode 100755 index 0000000..2cec8ba Binary files /dev/null and b/static/flags/ml.png differ diff --git a/static/flags/mm.png b/static/flags/mm.png new file mode 100755 index 0000000..f464f67 Binary files /dev/null and b/static/flags/mm.png differ diff --git a/static/flags/mn.png b/static/flags/mn.png new file mode 100755 index 0000000..9396355 Binary files /dev/null and b/static/flags/mn.png differ diff --git a/static/flags/mo.png b/static/flags/mo.png new file mode 100755 index 0000000..deb801d Binary files /dev/null and b/static/flags/mo.png differ diff --git a/static/flags/mp.png b/static/flags/mp.png new file mode 100755 index 0000000..298d588 Binary files /dev/null and b/static/flags/mp.png differ diff --git a/static/flags/mq.png b/static/flags/mq.png new file mode 100755 index 0000000..010143b Binary files /dev/null and b/static/flags/mq.png differ diff --git a/static/flags/mr.png b/static/flags/mr.png new file mode 100755 index 0000000..319546b Binary files /dev/null and b/static/flags/mr.png differ diff --git a/static/flags/ms.png b/static/flags/ms.png new file mode 100755 index 0000000..d4cbb43 Binary files /dev/null and b/static/flags/ms.png differ diff --git a/static/flags/mt.png b/static/flags/mt.png new file mode 100755 index 0000000..00af948 Binary files /dev/null and b/static/flags/mt.png differ diff --git a/static/flags/mu.png b/static/flags/mu.png new file mode 100755 index 0000000..b7fdce1 Binary files /dev/null and b/static/flags/mu.png differ diff --git a/static/flags/mv.png b/static/flags/mv.png new file mode 100755 index 0000000..5073d9e Binary files /dev/null and b/static/flags/mv.png differ diff --git a/static/flags/mw.png b/static/flags/mw.png new file mode 100755 index 0000000..13886e9 Binary files /dev/null and b/static/flags/mw.png differ diff --git a/static/flags/mx.png b/static/flags/mx.png new file mode 100755 index 0000000..5bc58ab Binary files /dev/null and b/static/flags/mx.png differ diff --git a/static/flags/my.png b/static/flags/my.png new file mode 100755 index 0000000..9034cba Binary files /dev/null and b/static/flags/my.png differ diff --git a/static/flags/mz.png b/static/flags/mz.png new file mode 100755 index 0000000..76405e0 Binary files /dev/null and b/static/flags/mz.png differ diff --git a/static/flags/na.png b/static/flags/na.png new file mode 100755 index 0000000..63358c6 Binary files /dev/null and b/static/flags/na.png differ diff --git a/static/flags/nc.png b/static/flags/nc.png new file mode 100755 index 0000000..2cad283 Binary files /dev/null and b/static/flags/nc.png differ diff --git a/static/flags/ne.png b/static/flags/ne.png new file mode 100755 index 0000000..d85f424 Binary files /dev/null and b/static/flags/ne.png differ diff --git a/static/flags/nf.png b/static/flags/nf.png new file mode 100755 index 0000000..f9bcdda Binary files /dev/null and b/static/flags/nf.png differ diff --git a/static/flags/ng.png b/static/flags/ng.png new file mode 100755 index 0000000..3eea2e0 Binary files /dev/null and b/static/flags/ng.png differ diff --git a/static/flags/ni.png b/static/flags/ni.png new file mode 100755 index 0000000..3969aaa Binary files /dev/null and b/static/flags/ni.png differ diff --git a/static/flags/nl.png b/static/flags/nl.png new file mode 100755 index 0000000..fe44791 Binary files /dev/null and b/static/flags/nl.png differ diff --git a/static/flags/no.png b/static/flags/no.png new file mode 100755 index 0000000..160b6b5 Binary files /dev/null and b/static/flags/no.png differ diff --git a/static/flags/np.png b/static/flags/np.png new file mode 100755 index 0000000..aeb058b Binary files /dev/null and b/static/flags/np.png differ diff --git a/static/flags/nr.png b/static/flags/nr.png new file mode 100755 index 0000000..705fc33 Binary files /dev/null and b/static/flags/nr.png differ diff --git a/static/flags/nu.png b/static/flags/nu.png new file mode 100755 index 0000000..c3ce4ae Binary files /dev/null and b/static/flags/nu.png differ diff --git a/static/flags/nz.png b/static/flags/nz.png new file mode 100755 index 0000000..10d6306 Binary files /dev/null and b/static/flags/nz.png differ diff --git a/static/flags/om.png b/static/flags/om.png new file mode 100755 index 0000000..2ffba7e Binary files /dev/null and b/static/flags/om.png differ diff --git a/static/flags/pa.png b/static/flags/pa.png new file mode 100755 index 0000000..9b2ee9a Binary files /dev/null and b/static/flags/pa.png differ diff --git a/static/flags/pe.png b/static/flags/pe.png new file mode 100755 index 0000000..62a0497 Binary files /dev/null and b/static/flags/pe.png differ diff --git a/static/flags/pf.png b/static/flags/pf.png new file mode 100755 index 0000000..771a0f6 Binary files /dev/null and b/static/flags/pf.png differ diff --git a/static/flags/pg.png b/static/flags/pg.png new file mode 100755 index 0000000..10d6233 Binary files /dev/null and b/static/flags/pg.png differ diff --git a/static/flags/ph.png b/static/flags/ph.png new file mode 100755 index 0000000..b89e159 Binary files /dev/null and b/static/flags/ph.png differ diff --git a/static/flags/pk.png b/static/flags/pk.png new file mode 100755 index 0000000..e9df70c Binary files /dev/null and b/static/flags/pk.png differ diff --git a/static/flags/pl.png b/static/flags/pl.png new file mode 100755 index 0000000..d413d01 Binary files /dev/null and b/static/flags/pl.png differ diff --git a/static/flags/pm.png b/static/flags/pm.png new file mode 100755 index 0000000..ba91d2c Binary files /dev/null and b/static/flags/pm.png differ diff --git a/static/flags/pn.png b/static/flags/pn.png new file mode 100755 index 0000000..aa9344f Binary files /dev/null and b/static/flags/pn.png differ diff --git a/static/flags/pr.png b/static/flags/pr.png new file mode 100755 index 0000000..82d9130 Binary files /dev/null and b/static/flags/pr.png differ diff --git a/static/flags/ps.png b/static/flags/ps.png new file mode 100755 index 0000000..f5f5477 Binary files /dev/null and b/static/flags/ps.png differ diff --git a/static/flags/pt.png b/static/flags/pt.png new file mode 100755 index 0000000..ece7980 Binary files /dev/null and b/static/flags/pt.png differ diff --git a/static/flags/pw.png b/static/flags/pw.png new file mode 100755 index 0000000..6178b25 Binary files /dev/null and b/static/flags/pw.png differ diff --git a/static/flags/py.png b/static/flags/py.png new file mode 100755 index 0000000..cb8723c Binary files /dev/null and b/static/flags/py.png differ diff --git a/static/flags/qa.png b/static/flags/qa.png new file mode 100755 index 0000000..ed4c621 Binary files /dev/null and b/static/flags/qa.png differ diff --git a/static/flags/re.png b/static/flags/re.png new file mode 100755 index 0000000..8332c4e Binary files /dev/null and b/static/flags/re.png differ diff --git a/static/flags/ro.png b/static/flags/ro.png new file mode 100755 index 0000000..57e74a6 Binary files /dev/null and b/static/flags/ro.png differ diff --git a/static/flags/rs.png b/static/flags/rs.png new file mode 100755 index 0000000..9439a5b Binary files /dev/null and b/static/flags/rs.png differ diff --git a/static/flags/ru.png b/static/flags/ru.png new file mode 100755 index 0000000..47da421 Binary files /dev/null and b/static/flags/ru.png differ diff --git a/static/flags/rw.png b/static/flags/rw.png new file mode 100755 index 0000000..5356491 Binary files /dev/null and b/static/flags/rw.png differ diff --git a/static/flags/sa.png b/static/flags/sa.png new file mode 100755 index 0000000..b4641c7 Binary files /dev/null and b/static/flags/sa.png differ diff --git a/static/flags/sb.png b/static/flags/sb.png new file mode 100755 index 0000000..a9937cc Binary files /dev/null and b/static/flags/sb.png differ diff --git a/static/flags/sc.png b/static/flags/sc.png new file mode 100755 index 0000000..39ee371 Binary files /dev/null and b/static/flags/sc.png differ diff --git a/static/flags/sd.png b/static/flags/sd.png new file mode 100755 index 0000000..eaab69e Binary files /dev/null and b/static/flags/sd.png differ diff --git a/static/flags/se.png b/static/flags/se.png new file mode 100755 index 0000000..1994653 Binary files /dev/null and b/static/flags/se.png differ diff --git a/static/flags/sg.png b/static/flags/sg.png new file mode 100755 index 0000000..dd34d61 Binary files /dev/null and b/static/flags/sg.png differ diff --git a/static/flags/sh.png b/static/flags/sh.png new file mode 100755 index 0000000..4b1d2a2 Binary files /dev/null and b/static/flags/sh.png differ diff --git a/static/flags/si.png b/static/flags/si.png new file mode 100755 index 0000000..bb1476f Binary files /dev/null and b/static/flags/si.png differ diff --git a/static/flags/sj.png b/static/flags/sj.png new file mode 100755 index 0000000..160b6b5 Binary files /dev/null and b/static/flags/sj.png differ diff --git a/static/flags/sk.png b/static/flags/sk.png new file mode 100755 index 0000000..7ccbc82 Binary files /dev/null and b/static/flags/sk.png differ diff --git a/static/flags/sl.png b/static/flags/sl.png new file mode 100755 index 0000000..12d812d Binary files /dev/null and b/static/flags/sl.png differ diff --git a/static/flags/sm.png b/static/flags/sm.png new file mode 100755 index 0000000..3df2fdc Binary files /dev/null and b/static/flags/sm.png differ diff --git a/static/flags/sn.png b/static/flags/sn.png new file mode 100755 index 0000000..eabb71d Binary files /dev/null and b/static/flags/sn.png differ diff --git a/static/flags/so.png b/static/flags/so.png new file mode 100755 index 0000000..4a1ea4b Binary files /dev/null and b/static/flags/so.png differ diff --git a/static/flags/sr.png b/static/flags/sr.png new file mode 100755 index 0000000..5eff927 Binary files /dev/null and b/static/flags/sr.png differ diff --git a/static/flags/st.png b/static/flags/st.png new file mode 100755 index 0000000..2978557 Binary files /dev/null and b/static/flags/st.png differ diff --git a/static/flags/sv.png b/static/flags/sv.png new file mode 100755 index 0000000..2498799 Binary files /dev/null and b/static/flags/sv.png differ diff --git a/static/flags/sy.png b/static/flags/sy.png new file mode 100755 index 0000000..f5ce30d Binary files /dev/null and b/static/flags/sy.png differ diff --git a/static/flags/sz.png b/static/flags/sz.png new file mode 100755 index 0000000..914ee86 Binary files /dev/null and b/static/flags/sz.png differ diff --git a/static/flags/tc.png b/static/flags/tc.png new file mode 100755 index 0000000..8fc1156 Binary files /dev/null and b/static/flags/tc.png differ diff --git a/static/flags/td.png b/static/flags/td.png new file mode 100755 index 0000000..667f21f Binary files /dev/null and b/static/flags/td.png differ diff --git a/static/flags/tf.png b/static/flags/tf.png new file mode 100755 index 0000000..80529a4 Binary files /dev/null and b/static/flags/tf.png differ diff --git a/static/flags/tg.png b/static/flags/tg.png new file mode 100755 index 0000000..3aa00ad Binary files /dev/null and b/static/flags/tg.png differ diff --git a/static/flags/th.png b/static/flags/th.png new file mode 100755 index 0000000..dd8ba91 Binary files /dev/null and b/static/flags/th.png differ diff --git a/static/flags/tj.png b/static/flags/tj.png new file mode 100755 index 0000000..617bf64 Binary files /dev/null and b/static/flags/tj.png differ diff --git a/static/flags/tk.png b/static/flags/tk.png new file mode 100755 index 0000000..67b8c8c Binary files /dev/null and b/static/flags/tk.png differ diff --git a/static/flags/tl.png b/static/flags/tl.png new file mode 100755 index 0000000..77da181 Binary files /dev/null and b/static/flags/tl.png differ diff --git a/static/flags/tm.png b/static/flags/tm.png new file mode 100755 index 0000000..828020e Binary files /dev/null and b/static/flags/tm.png differ diff --git a/static/flags/tn.png b/static/flags/tn.png new file mode 100755 index 0000000..183cdd3 Binary files /dev/null and b/static/flags/tn.png differ diff --git a/static/flags/to.png b/static/flags/to.png new file mode 100755 index 0000000..f89b8ba Binary files /dev/null and b/static/flags/to.png differ diff --git a/static/flags/tr.png b/static/flags/tr.png new file mode 100755 index 0000000..be32f77 Binary files /dev/null and b/static/flags/tr.png differ diff --git a/static/flags/tt.png b/static/flags/tt.png new file mode 100755 index 0000000..2a11c1e Binary files /dev/null and b/static/flags/tt.png differ diff --git a/static/flags/tv.png b/static/flags/tv.png new file mode 100755 index 0000000..28274c5 Binary files /dev/null and b/static/flags/tv.png differ diff --git a/static/flags/tw.png b/static/flags/tw.png new file mode 100755 index 0000000..f31c654 Binary files /dev/null and b/static/flags/tw.png differ diff --git a/static/flags/tz.png b/static/flags/tz.png new file mode 100755 index 0000000..c00ff79 Binary files /dev/null and b/static/flags/tz.png differ diff --git a/static/flags/ua.png b/static/flags/ua.png new file mode 100755 index 0000000..09563a2 Binary files /dev/null and b/static/flags/ua.png differ diff --git a/static/flags/ug.png b/static/flags/ug.png new file mode 100755 index 0000000..33f4aff Binary files /dev/null and b/static/flags/ug.png differ diff --git a/static/flags/um.png b/static/flags/um.png new file mode 100755 index 0000000..c1dd965 Binary files /dev/null and b/static/flags/um.png differ diff --git a/static/flags/us.png b/static/flags/us.png new file mode 100755 index 0000000..10f451f Binary files /dev/null and b/static/flags/us.png differ diff --git a/static/flags/uy.png b/static/flags/uy.png new file mode 100755 index 0000000..31d948a Binary files /dev/null and b/static/flags/uy.png differ diff --git a/static/flags/uz.png b/static/flags/uz.png new file mode 100755 index 0000000..fef5dc1 Binary files /dev/null and b/static/flags/uz.png differ diff --git a/static/flags/va.png b/static/flags/va.png new file mode 100755 index 0000000..b31eaf2 Binary files /dev/null and b/static/flags/va.png differ diff --git a/static/flags/vc.png b/static/flags/vc.png new file mode 100755 index 0000000..8fa17b0 Binary files /dev/null and b/static/flags/vc.png differ diff --git a/static/flags/ve.png b/static/flags/ve.png new file mode 100755 index 0000000..00c90f9 Binary files /dev/null and b/static/flags/ve.png differ diff --git a/static/flags/vg.png b/static/flags/vg.png new file mode 100755 index 0000000..4156907 Binary files /dev/null and b/static/flags/vg.png differ diff --git a/static/flags/vi.png b/static/flags/vi.png new file mode 100755 index 0000000..ed26915 Binary files /dev/null and b/static/flags/vi.png differ diff --git a/static/flags/vn.png b/static/flags/vn.png new file mode 100755 index 0000000..ec7cd48 Binary files /dev/null and b/static/flags/vn.png differ diff --git a/static/flags/vu.png b/static/flags/vu.png new file mode 100755 index 0000000..b3397bc Binary files /dev/null and b/static/flags/vu.png differ diff --git a/static/flags/wf.png b/static/flags/wf.png new file mode 100755 index 0000000..9f95587 Binary files /dev/null and b/static/flags/wf.png differ diff --git a/static/flags/ws.png b/static/flags/ws.png new file mode 100755 index 0000000..c169508 Binary files /dev/null and b/static/flags/ws.png differ diff --git a/static/flags/xx.png b/static/flags/xx.png new file mode 100755 index 0000000..ba351ca Binary files /dev/null and b/static/flags/xx.png differ diff --git a/static/flags/ye.png b/static/flags/ye.png new file mode 100755 index 0000000..468dfad Binary files /dev/null and b/static/flags/ye.png differ diff --git a/static/flags/yt.png b/static/flags/yt.png new file mode 100755 index 0000000..c298f37 Binary files /dev/null and b/static/flags/yt.png differ diff --git a/static/flags/za.png b/static/flags/za.png new file mode 100755 index 0000000..57c58e2 Binary files /dev/null and b/static/flags/za.png differ diff --git a/static/flags/zm.png b/static/flags/zm.png new file mode 100755 index 0000000..c25b07b Binary files /dev/null and b/static/flags/zm.png differ diff --git a/static/flags/zw.png b/static/flags/zw.png new file mode 100755 index 0000000..53c9725 Binary files /dev/null and b/static/flags/zw.png differ diff --git a/static/icons/locked.png b/static/icons/locked.png new file mode 100755 index 0000000..af561c1 Binary files /dev/null and b/static/icons/locked.png differ diff --git a/static/icons/sticky.png b/static/icons/sticky.png new file mode 100755 index 0000000..21bfc96 Binary files /dev/null and b/static/icons/sticky.png differ diff --git a/static/js/autofill.js b/static/js/autofill.js new file mode 100644 index 0000000..cb3a8b4 --- /dev/null +++ b/static/js/autofill.js @@ -0,0 +1,52 @@ +$(function () { + let name = get_cookie("name"); + let password = get_cookie("password"); + let email = get_cookie("email"); + + if (password === "") { + password = generate_password(); + set_cookie("password", password); + } + + $('input[name="post_name"]').attr("value", name); + $('input[name="post_password"]').attr("value", password); + $('input[name="email"]').attr("value", email); + + function generate_password() { + let chars = + "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; + let password_length = 8; + let password = ""; + + for (let i = 0; i <= password_length; i++) { + let random_number = Math.floor(Math.random() * chars.length); + password += chars.substring(random_number, random_number + 1); + } + + return password; + } + + function get_cookie(cname) { + let name = cname + "="; + let decodedCookie = decodeURIComponent(document.cookie); + let ca = decodedCookie.split(";"); + + for (let i = 0; i < ca.length; i++) { + let c = ca[i]; + + while (c.charAt(0) == " ") { + c = c.substring(1); + } + + if (c.indexOf(name) == 0) { + return c.substring(name.length, c.length); + } + } + + return ""; + } + + function set_cookie(cname, cvalue) { + document.cookie = `${cname}=${cvalue};path=/`; + } +}); diff --git a/static/js/captcha.js b/static/js/captcha.js new file mode 100644 index 0000000..2256bfd --- /dev/null +++ b/static/js/captcha.js @@ -0,0 +1,25 @@ +$(function () { + $("#get-captcha").click(function () { + let btn = $(this); + + let board = btn.attr("data-board"); + let reply = btn.attr("data-reply"); + let req_url = `/captcha?board=${board}&reply=${reply}`; + + btn.text("Získat CAPTCHA"); + btn.attr("disabled", true); + btn.addClass("loading"); + + $.get(req_url, function (data, _) { + try { + $("#captcha-id").attr("value", data.id); + $("#captcha").html(``); + } catch { + btn.append(" [Chyba]"); + } + + btn.attr("disabled", false); + btn.removeClass("loading"); + }); + }); +}); diff --git a/static/js/expand.js b/static/js/expand.js new file mode 100644 index 0000000..6dc20ce --- /dev/null +++ b/static/js/expand.js @@ -0,0 +1,103 @@ +$(function () { + $(window).on("setup_post_events", function (event) { + setup_events($(`#${event.id}`).find(".expandable")); + }); + + setup_events($(".expandable")); + + function setup_events(elements) { + elements.each(function() { + $(this).click(function() { + let src_link = $(this).attr("href"); + + let is_video = [".mpeg", ".mov", ".mp4", ".webm", ".mkv", ".ogg"].some( + (ext) => src_link.endsWith(ext) + ); + + if (!is_video) { + toggle_image($(this), src_link); + } else { + toggle_video($(this), src_link); + } + + $(this).toggleClass("expanded"); + + return false; + }) + }) + } + + function toggle_image(parent, src_link) { + let thumb = parent.find(".thumb"); + let src = parent.find(".src"); + + if (src.length === 0) { + thumb.addClass("loading"); + + parent.append(``); + + let src = parent.find(".src"); + + src.hide(); + src.on("load", function () { + thumb.removeClass("loading"); + thumb.hide(); + src.show(); + + parent.closest(".post-files").addClass("float-none-b"); + }); + + return; + } + + thumb.toggle(); + src.toggle(); + + parent.closest(".post-files").toggleClass("float-none-b"); + } + + function toggle_video(parent, src_link) { + let expanded = parent.hasClass("expanded"); + let thumb = parent.find(".thumb"); + let src = parent.parent().find(".src"); + + if (src.length === 0) { + thumb.addClass("loading"); + + parent.append('[Zavřít]
'); + parent + .parent() + .append( + `` + ); + + let src = parent.parent().find(".src"); + + src.hide(); + + src.on("loadstart", function () { + thumb.removeClass("loading"); + thumb.hide(); + src.show(); + src.get(0).play(); + + parent.closest(".post-files").addClass("float-none-b"); + }); + + return; + } + + thumb.toggle(); + src.toggle(); + + if (expanded) { + src.get(0).pause(); + src.get(0).currentTime = 0; + } else { + src.get(0).play(); + } + + parent.closest(".post-files").toggleClass("float-none-b"); + parent.find(".closer").toggle(); + } +}); diff --git a/static/js/hover.js b/static/js/hover.js new file mode 100644 index 0000000..6e29970 --- /dev/null +++ b/static/js/hover.js @@ -0,0 +1,138 @@ +$.fn.isInViewport = function () { + let element_top = $(this).offset().top; + let element_bottom = element_top + $(this).outerHeight(); + let viewport_top = $(window).scrollTop(); + let viewport_bottom = viewport_top + $(window).height(); + + return element_bottom > viewport_top && element_top < viewport_bottom; +}; + +$(function () { + let cache = {}; + let hovering = false; + let preview_w = 0; + let preview_h = 0; + + $(window).on("setup_post_events", function (event) { + setup_events($(`#${event.id}`).find(".quote")); + }); + + setup_events($(".quote")); + + function setup_events(elements) { + elements.on("mouseover", function (event) { + toggle_hover($(this), event); + }); + + elements.on("mouseout", function (event) { + toggle_hover($(this), event); + }); + + elements.on("click", function (event) { + toggle_hover($(this), event); + }); + + elements.on("mousemove", move_preview); + } + + function toggle_hover(quote, event) { + hovering = event.type === "mouseover"; + + if ($("#preview").length !== 0 && !hovering) { + remove_preview(); + return; + } + + let path_segments = quote.prop("pathname").split("/"); + let board = path_segments[2]; + let thread = path_segments[3]; + let id = quote.prop("hash").slice(1); + + let post = $(`#${id}[data-board="${board}"]`); + + if (post.length !== 0 && post.isInViewport()) { + post.toggleClass("highlighted", hovering); + return; + } + + if (post.length !== 0 && hovering) { + create_preview(post.clone(), event.clientX, event.clientY); + return; + } + + let html; + let cached_thread = cache[`${board}/${thread}`]; + + if (cached_thread) { + html = cached_thread[id]; + post = $($.parseHTML(html)); + create_preview(post, event.clientX, event.clientY); + return; + } + + quote.css("cursor", "wait"); + + try { + $.get(`/thread-json/${board}/${thread}`, function (data) { + quote.css("cursor", ""); + cache[`${board}/${thread}`] = data; + html = data[id]; + post = $($.parseHTML(html)); + + create_preview(post, event.clientX, event.clientY); + }); + } catch (e) { + quote.css("cursor", ""); + console.error(e); + } + } + + function move_preview(event) { + position_preview($("#preview"), event.clientX, event.clientY); + } + + function create_preview(preview, x, y) { + if (!hovering) { + return; + } + + preview.attr("id", "preview"); + preview.addClass("box"); + preview.removeClass("highlighted"); + preview.css("position", "fixed"); + + let existing = $("#preview"); + + if (existing.length !== 0) { + existing.replaceWith(preview); + } else { + preview.appendTo("body"); + } + + preview_w = preview.outerWidth(); + preview_h = preview.outerHeight(); + + position_preview(preview, x, y); + + $(window).trigger({ type: "setup_post_events", id: "preview" }); + } + + function remove_preview() { + $("#preview").remove(); + } + + function position_preview(preview, x, y) { + let ww = $(window).width(); + let wh = $(window).height(); + + preview.css("left", `${Math.min(x + 4, ww - preview_w)}px`); + + if (preview_h + y < wh) { + preview.css("top", `${y + 4}px`); + preview.css("bottom", ""); + } else { + preview.css("bottom", `${wh - y + 4}px`); + preview.css("top", ""); + } + } +}); diff --git a/static/js/jquery.min.js b/static/js/jquery.min.js new file mode 100644 index 0000000..43dcd5a --- /dev/null +++ b/static/js/jquery.min.js @@ -0,0 +1,2 @@ +/*! jQuery v3.7.1 | (c) OpenJS Foundation and other contributors | jquery.org/license */ +!function(e,t){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=e.document?t(e,!0):function(e){if(!e.document)throw new Error("jQuery requires a window with a document");return t(e)}:t(e)}("undefined"!=typeof window?window:this,function(ie,e){"use strict";var oe=[],r=Object.getPrototypeOf,ae=oe.slice,g=oe.flat?function(e){return oe.flat.call(e)}:function(e){return oe.concat.apply([],e)},s=oe.push,se=oe.indexOf,n={},i=n.toString,ue=n.hasOwnProperty,o=ue.toString,a=o.call(Object),le={},v=function(e){return"function"==typeof e&&"number"!=typeof e.nodeType&&"function"!=typeof e.item},y=function(e){return null!=e&&e===e.window},C=ie.document,u={type:!0,src:!0,nonce:!0,noModule:!0};function m(e,t,n){var r,i,o=(n=n||C).createElement("script");if(o.text=e,t)for(r in u)(i=t[r]||t.getAttribute&&t.getAttribute(r))&&o.setAttribute(r,i);n.head.appendChild(o).parentNode.removeChild(o)}function x(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?n[i.call(e)]||"object":typeof e}var t="3.7.1",l=/HTML$/i,ce=function(e,t){return new ce.fn.init(e,t)};function c(e){var t=!!e&&"length"in e&&e.length,n=x(e);return!v(e)&&!y(e)&&("array"===n||0===t||"number"==typeof t&&0+~]|"+ge+")"+ge+"*"),x=new RegExp(ge+"|>"),j=new RegExp(g),A=new RegExp("^"+t+"$"),D={ID:new RegExp("^#("+t+")"),CLASS:new RegExp("^\\.("+t+")"),TAG:new RegExp("^("+t+"|[*])"),ATTR:new RegExp("^"+p),PSEUDO:new RegExp("^"+g),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+ge+"*(even|odd|(([+-]|)(\\d*)n|)"+ge+"*(?:([+-]|)"+ge+"*(\\d+)|))"+ge+"*\\)|)","i"),bool:new RegExp("^(?:"+f+")$","i"),needsContext:new RegExp("^"+ge+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+ge+"*((?:-\\d)?\\d*)"+ge+"*\\)|)(?=[^-]|$)","i")},N=/^(?:input|select|textarea|button)$/i,q=/^h\d$/i,L=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,H=/[+~]/,O=new RegExp("\\\\[\\da-fA-F]{1,6}"+ge+"?|\\\\([^\\r\\n\\f])","g"),P=function(e,t){var n="0x"+e.slice(1)-65536;return t||(n<0?String.fromCharCode(n+65536):String.fromCharCode(n>>10|55296,1023&n|56320))},M=function(){V()},R=J(function(e){return!0===e.disabled&&fe(e,"fieldset")},{dir:"parentNode",next:"legend"});try{k.apply(oe=ae.call(ye.childNodes),ye.childNodes),oe[ye.childNodes.length].nodeType}catch(e){k={apply:function(e,t){me.apply(e,ae.call(t))},call:function(e){me.apply(e,ae.call(arguments,1))}}}function I(t,e,n,r){var i,o,a,s,u,l,c,f=e&&e.ownerDocument,p=e?e.nodeType:9;if(n=n||[],"string"!=typeof t||!t||1!==p&&9!==p&&11!==p)return n;if(!r&&(V(e),e=e||T,C)){if(11!==p&&(u=L.exec(t)))if(i=u[1]){if(9===p){if(!(a=e.getElementById(i)))return n;if(a.id===i)return k.call(n,a),n}else if(f&&(a=f.getElementById(i))&&I.contains(e,a)&&a.id===i)return k.call(n,a),n}else{if(u[2])return k.apply(n,e.getElementsByTagName(t)),n;if((i=u[3])&&e.getElementsByClassName)return k.apply(n,e.getElementsByClassName(i)),n}if(!(h[t+" "]||d&&d.test(t))){if(c=t,f=e,1===p&&(x.test(t)||m.test(t))){(f=H.test(t)&&U(e.parentNode)||e)==e&&le.scope||((s=e.getAttribute("id"))?s=ce.escapeSelector(s):e.setAttribute("id",s=S)),o=(l=Y(t)).length;while(o--)l[o]=(s?"#"+s:":scope")+" "+Q(l[o]);c=l.join(",")}try{return k.apply(n,f.querySelectorAll(c)),n}catch(e){h(t,!0)}finally{s===S&&e.removeAttribute("id")}}}return re(t.replace(ve,"$1"),e,n,r)}function W(){var r=[];return function e(t,n){return r.push(t+" ")>b.cacheLength&&delete e[r.shift()],e[t+" "]=n}}function F(e){return e[S]=!0,e}function $(e){var t=T.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function B(t){return function(e){return fe(e,"input")&&e.type===t}}function _(t){return function(e){return(fe(e,"input")||fe(e,"button"))&&e.type===t}}function z(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&R(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function X(a){return F(function(o){return o=+o,F(function(e,t){var n,r=a([],e.length,o),i=r.length;while(i--)e[n=r[i]]&&(e[n]=!(t[n]=e[n]))})})}function U(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}function V(e){var t,n=e?e.ownerDocument||e:ye;return n!=T&&9===n.nodeType&&n.documentElement&&(r=(T=n).documentElement,C=!ce.isXMLDoc(T),i=r.matches||r.webkitMatchesSelector||r.msMatchesSelector,r.msMatchesSelector&&ye!=T&&(t=T.defaultView)&&t.top!==t&&t.addEventListener("unload",M),le.getById=$(function(e){return r.appendChild(e).id=ce.expando,!T.getElementsByName||!T.getElementsByName(ce.expando).length}),le.disconnectedMatch=$(function(e){return i.call(e,"*")}),le.scope=$(function(){return T.querySelectorAll(":scope")}),le.cssHas=$(function(){try{return T.querySelector(":has(*,:jqfake)"),!1}catch(e){return!0}}),le.getById?(b.filter.ID=function(e){var t=e.replace(O,P);return function(e){return e.getAttribute("id")===t}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&C){var n=t.getElementById(e);return n?[n]:[]}}):(b.filter.ID=function(e){var n=e.replace(O,P);return function(e){var t="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return t&&t.value===n}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&C){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),b.find.TAG=function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):t.querySelectorAll(e)},b.find.CLASS=function(e,t){if("undefined"!=typeof t.getElementsByClassName&&C)return t.getElementsByClassName(e)},d=[],$(function(e){var t;r.appendChild(e).innerHTML="",e.querySelectorAll("[selected]").length||d.push("\\["+ge+"*(?:value|"+f+")"),e.querySelectorAll("[id~="+S+"-]").length||d.push("~="),e.querySelectorAll("a#"+S+"+*").length||d.push(".#.+[+~]"),e.querySelectorAll(":checked").length||d.push(":checked"),(t=T.createElement("input")).setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),r.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&d.push(":enabled",":disabled"),(t=T.createElement("input")).setAttribute("name",""),e.appendChild(t),e.querySelectorAll("[name='']").length||d.push("\\["+ge+"*name"+ge+"*="+ge+"*(?:''|\"\")")}),le.cssHas||d.push(":has"),d=d.length&&new RegExp(d.join("|")),l=function(e,t){if(e===t)return a=!0,0;var n=!e.compareDocumentPosition-!t.compareDocumentPosition;return n||(1&(n=(e.ownerDocument||e)==(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!le.sortDetached&&t.compareDocumentPosition(e)===n?e===T||e.ownerDocument==ye&&I.contains(ye,e)?-1:t===T||t.ownerDocument==ye&&I.contains(ye,t)?1:o?se.call(o,e)-se.call(o,t):0:4&n?-1:1)}),T}for(e in I.matches=function(e,t){return I(e,null,null,t)},I.matchesSelector=function(e,t){if(V(e),C&&!h[t+" "]&&(!d||!d.test(t)))try{var n=i.call(e,t);if(n||le.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(e){h(t,!0)}return 0":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(O,P),e[3]=(e[3]||e[4]||e[5]||"").replace(O,P),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||I.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&I.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return D.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&j.test(n)&&(t=Y(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(O,P).toLowerCase();return"*"===e?function(){return!0}:function(e){return fe(e,t)}},CLASS:function(e){var t=s[e+" "];return t||(t=new RegExp("(^|"+ge+")"+e+"("+ge+"|$)"))&&s(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(n,r,i){return function(e){var t=I.attr(e,n);return null==t?"!="===r:!r||(t+="","="===r?t===i:"!="===r?t!==i:"^="===r?i&&0===t.indexOf(i):"*="===r?i&&-1:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function T(e,n,r){return v(n)?ce.grep(e,function(e,t){return!!n.call(e,t,e)!==r}):n.nodeType?ce.grep(e,function(e){return e===n!==r}):"string"!=typeof n?ce.grep(e,function(e){return-1)[^>]*|#([\w-]+))$/;(ce.fn.init=function(e,t,n){var r,i;if(!e)return this;if(n=n||k,"string"==typeof e){if(!(r="<"===e[0]&&">"===e[e.length-1]&&3<=e.length?[null,e,null]:S.exec(e))||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof ce?t[0]:t,ce.merge(this,ce.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:C,!0)),w.test(r[1])&&ce.isPlainObject(t))for(r in t)v(this[r])?this[r](t[r]):this.attr(r,t[r]);return this}return(i=C.getElementById(r[2]))&&(this[0]=i,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):v(e)?void 0!==n.ready?n.ready(e):e(ce):ce.makeArray(e,this)}).prototype=ce.fn,k=ce(C);var E=/^(?:parents|prev(?:Until|All))/,j={children:!0,contents:!0,next:!0,prev:!0};function A(e,t){while((e=e[t])&&1!==e.nodeType);return e}ce.fn.extend({has:function(e){var t=ce(e,this),n=t.length;return this.filter(function(){for(var e=0;e\x20\t\r\n\f]*)/i,Ce=/^$|^module$|\/(?:java|ecma)script/i;xe=C.createDocumentFragment().appendChild(C.createElement("div")),(be=C.createElement("input")).setAttribute("type","radio"),be.setAttribute("checked","checked"),be.setAttribute("name","t"),xe.appendChild(be),le.checkClone=xe.cloneNode(!0).cloneNode(!0).lastChild.checked,xe.innerHTML="",le.noCloneChecked=!!xe.cloneNode(!0).lastChild.defaultValue,xe.innerHTML="",le.option=!!xe.lastChild;var ke={thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};function Se(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&fe(e,t)?ce.merge([e],n):n}function Ee(e,t){for(var n=0,r=e.length;n",""]);var je=/<|&#?\w+;/;function Ae(e,t,n,r,i){for(var o,a,s,u,l,c,f=t.createDocumentFragment(),p=[],d=0,h=e.length;d\s*$/g;function Re(e,t){return fe(e,"table")&&fe(11!==t.nodeType?t:t.firstChild,"tr")&&ce(e).children("tbody")[0]||e}function Ie(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function We(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Fe(e,t){var n,r,i,o,a,s;if(1===t.nodeType){if(_.hasData(e)&&(s=_.get(e).events))for(i in _.remove(t,"handle events"),s)for(n=0,r=s[i].length;n").attr(n.scriptAttrs||{}).attr({charset:n.scriptCharset,src:n.url}).on("load error",i=function(e){r.remove(),i=null,e&&t("error"===e.type?404:200,e.type)}),C.head.appendChild(r[0])},abort:function(){i&&i()}}});var Jt,Kt=[],Zt=/(=)\?(?=&|$)|\?\?/;ce.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=Kt.pop()||ce.expando+"_"+jt.guid++;return this[e]=!0,e}}),ce.ajaxPrefilter("json jsonp",function(e,t,n){var r,i,o,a=!1!==e.jsonp&&(Zt.test(e.url)?"url":"string"==typeof e.data&&0===(e.contentType||"").indexOf("application/x-www-form-urlencoded")&&Zt.test(e.data)&&"data");if(a||"jsonp"===e.dataTypes[0])return r=e.jsonpCallback=v(e.jsonpCallback)?e.jsonpCallback():e.jsonpCallback,a?e[a]=e[a].replace(Zt,"$1"+r):!1!==e.jsonp&&(e.url+=(At.test(e.url)?"&":"?")+e.jsonp+"="+r),e.converters["script json"]=function(){return o||ce.error(r+" was not called"),o[0]},e.dataTypes[0]="json",i=ie[r],ie[r]=function(){o=arguments},n.always(function(){void 0===i?ce(ie).removeProp(r):ie[r]=i,e[r]&&(e.jsonpCallback=t.jsonpCallback,Kt.push(r)),o&&v(i)&&i(o[0]),o=i=void 0}),"script"}),le.createHTMLDocument=((Jt=C.implementation.createHTMLDocument("").body).innerHTML="
",2===Jt.childNodes.length),ce.parseHTML=function(e,t,n){return"string"!=typeof e?[]:("boolean"==typeof t&&(n=t,t=!1),t||(le.createHTMLDocument?((r=(t=C.implementation.createHTMLDocument("")).createElement("base")).href=C.location.href,t.head.appendChild(r)):t=C),o=!n&&[],(i=w.exec(e))?[t.createElement(i[1])]:(i=Ae([e],t,o),o&&o.length&&ce(o).remove(),ce.merge([],i.childNodes)));var r,i,o},ce.fn.load=function(e,t,n){var r,i,o,a=this,s=e.indexOf(" ");return-1").append(ce.parseHTML(e)).find(r):e)}).always(n&&function(e,t){a.each(function(){n.apply(this,o||[e.responseText,t,e])})}),this},ce.expr.pseudos.animated=function(t){return ce.grep(ce.timers,function(e){return t===e.elem}).length},ce.offset={setOffset:function(e,t,n){var r,i,o,a,s,u,l=ce.css(e,"position"),c=ce(e),f={};"static"===l&&(e.style.position="relative"),s=c.offset(),o=ce.css(e,"top"),u=ce.css(e,"left"),("absolute"===l||"fixed"===l)&&-1<(o+u).indexOf("auto")?(a=(r=c.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(u)||0),v(t)&&(t=t.call(e,n,ce.extend({},s))),null!=t.top&&(f.top=t.top-s.top+a),null!=t.left&&(f.left=t.left-s.left+i),"using"in t?t.using.call(e,f):c.css(f)}},ce.fn.extend({offset:function(t){if(arguments.length)return void 0===t?this:this.each(function(e){ce.offset.setOffset(this,t,e)});var e,n,r=this[0];return r?r.getClientRects().length?(e=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:e.top+n.pageYOffset,left:e.left+n.pageXOffset}):{top:0,left:0}:void 0},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===ce.css(r,"position"))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&"static"===ce.css(e,"position"))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=ce(e).offset()).top+=ce.css(e,"borderTopWidth",!0),i.left+=ce.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-ce.css(r,"marginTop",!0),left:t.left-i.left-ce.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&"static"===ce.css(e,"position"))e=e.offsetParent;return e||J})}}),ce.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(t,i){var o="pageYOffset"===i;ce.fn[t]=function(e){return M(this,function(e,t,n){var r;if(y(e)?r=e:9===e.nodeType&&(r=e.defaultView),void 0===n)return r?r[i]:e[t];r?r.scrollTo(o?r.pageXOffset:n,o?n:r.pageYOffset):e[t]=n},t,e,arguments.length)}}),ce.each(["top","left"],function(e,n){ce.cssHooks[n]=Ye(le.pixelPosition,function(e,t){if(t)return t=Ge(e,n),_e.test(t)?ce(e).position()[n]+"px":t})}),ce.each({Height:"height",Width:"width"},function(a,s){ce.each({padding:"inner"+a,content:s,"":"outer"+a},function(r,o){ce.fn[o]=function(e,t){var n=arguments.length&&(r||"boolean"!=typeof e),i=r||(!0===e||!0===t?"margin":"border");return M(this,function(e,t,n){var r;return y(e)?0===o.indexOf("outer")?e["inner"+a]:e.document.documentElement["client"+a]:9===e.nodeType?(r=e.documentElement,Math.max(e.body["scroll"+a],r["scroll"+a],e.body["offset"+a],r["offset"+a],r["client"+a])):void 0===n?ce.css(e,t,i):ce.style(e,t,n,i)},s,n?e:void 0,n)}})}),ce.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){ce.fn[t]=function(e){return this.on(t,e)}}),ce.fn.extend({bind:function(e,t,n){return this.on(e,null,t,n)},unbind:function(e,t){return this.off(e,null,t)},delegate:function(e,t,n,r){return this.on(t,e,n,r)},undelegate:function(e,t,n){return 1===arguments.length?this.off(e,"**"):this.off(t,e||"**",n)},hover:function(e,t){return this.on("mouseenter",e).on("mouseleave",t||e)}}),ce.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,n){ce.fn[n]=function(e,t){return 0
' + ); + + $("#live-indicator").css("background-color", "orange"); + $("#live-status").text("Připojování..."); + + let thread = window.location.pathname.split("/").slice(-2); + let protocol; + + if (window.location.protocol === "https:") { + protocol = "wss:"; + } else { + protocol = "ws:"; + } + + let last_post = $(".thread").find(".post").last().attr("id"); + + let ws_location = `${protocol}//${window.location.host}/live/${thread[0]}/${thread[1]}/${last_post}`; + let ws = new WebSocket(ws_location); + let interval; + + ws.addEventListener("open", function (_) { + $("#live-indicator").css("background-color", "lime"); + $("#live-status").text("Připojeno pro nové příspěvky"); + + interval = setInterval(function () { + ws.send('{"type":"ping"}'); + }, 10000); + }); + + ws.addEventListener("message", function (msg) { + let data = JSON.parse(msg.data); + + switch (data.type) { + case "created": + $(".thread").append(data.html + "
"); + $(window).trigger({ + type: "setup_post_events", + id: data.id, + }); + break; + case "updated": + $(`#${data.id}`).replaceWith(data.html); + $(window).trigger({ + type: "setup_post_events", + id: data.id, + }); + break; + case "removed": + $(`#${data.id}`).next("br").remove(); + $(`#${data.id}`).remove(); + break; + case "thread_removed": + setTimeout(function () { + $("#live-indicator").css("background-color", "red"); + $("#live-status").text("Vlákno bylo odstraněno"); + }, 100); + break; + default: + break; + } + }); + + ws.addEventListener("close", function (_) { + $("#live-indicator").css("background-color", "red"); + $("#live-status").text("Odpojeno, obnov stránku"); + clearInterval(interval); + }); +}); diff --git a/static/js/post-form.js b/static/js/post-form.js new file mode 100644 index 0000000..b58324c --- /dev/null +++ b/static/js/post-form.js @@ -0,0 +1,173 @@ +$(function () { + $(".open-post-form").click(function () { + $("#post-form").attr("data-visible", true); + + return false; + }); + + $(".close-post-form").click(function () { + if (document.location.hash == "#post-form") { + document.location.hash = ""; + } + + $("#post-form").attr("data-visible", false); + + return false; + }); +}); + +// Stolen and modified code from jschan +$(function () { + let dragging = false; + let x_offset = 0; + let y_offset = 0; + let form = $("#post-form"); + let handle = $("#post-form-handle"); + + let saved_top = window.localStorage.getItem("post_form_top"); + let saved_left = window.localStorage.getItem("post_form_left"); + + if (saved_top) { + form.css("top", saved_top); + } + + if (saved_left) { + form.css("left", saved_left); + form.css("right", "auto"); + } + + handle.on("mousedown", start); + handle.get(0).addEventListener("touchstart", start, { passive: true }); + $(document).on("mouseup", stop); + $(document).on("touchend", stop); + $(window).on("resize", update_max); + $(window).on("orientationchange", update_max); + + function start(event) { + dragging = true; + + const rect = form.get(0).getBoundingClientRect(); + + switch (event.type) { + case "mousedown": + x_offset = event.clientX - rect.left; + y_offset = event.clientY - rect.top; + $(window).on("mousemove", drag); + break; + case "touchstart": + event.preventDefault(); + event.stopPropagation(); + x_offset = event.targetTouches[0].clientX - rect.left; + y_offset = event.targetTouches[0].clientY - rect.top; + $(window).on("touchmove", drag); + break; + default: + break; + } + } + + function drag(event) { + if (!dragging) { + return; + } + + update_max(event); + + switch (event.type) { + case "mousemove": + form.css( + "left", + `${in_bounds( + event.clientX, + x_offset, + form.outerWidth(), + document.documentElement.clientWidth + )}px` + ); + form.css( + "top", + `${in_bounds( + event.clientY, + y_offset, + form.outerHeight(), + document.documentElement.clientHeight + )}px` + ); + break; + case "touchmove": + form.css( + "left", + `${in_bounds( + event.targetTouches[0].clientX, + x_offset, + form.outerWidth(), + document.documentElement.clientWidth + )}px` + ); + form.css( + "top", + `${in_bounds( + event.targetTouches[0].clientY, + y_offset, + form.outerHeight(), + document.documentElement.clientHeight + )}px` + ); + break; + default: + break; + } + + form.css("right", "auto"); + + window.localStorage.setItem("post_form_top", form.css("top")); + window.localStorage.setItem("post_form_left", form.css("left")); + } + + function stop() { + if (dragging) { + dragging = false; + $(window).off("mousemove"); + $(window).off("touchmove"); + } + } + + function update_max() { + let rect = form.get(0).getBoundingClientRect(); + + if (rect.width === 0) { + return; + } + + if (Math.floor(rect.right) > document.documentElement.clientWidth) { + form.css("left", 0); + form.css("right", "auto"); + } + + if (Math.floor(rect.bottom) > document.documentElement.clientHeight) { + form.css("top", 0); + } + + rect = form.get(0).getBoundingClientRect(); + + form.css( + "max-height", + `${document.documentElement.clientHeight - rect.top}px` + ); + + form.css( + "max-width", + `${document.documentElement.clientWidth - rect.left}px` + ); + } + + function in_bounds(pos, offset, size, limit) { + if (pos - offset <= 0) { + return 0; + } else if (pos - offset + size > limit) { + return limit - size; + } else { + return pos - offset; + } + } +}); diff --git a/static/js/quote.js b/static/js/quote.js new file mode 100644 index 0000000..27cc4e3 --- /dev/null +++ b/static/js/quote.js @@ -0,0 +1,36 @@ +$(function () { + let quoted_post = window.localStorage.getItem("quoted_post"); + + if (quoted_post) { + $("#post-form").attr("data-visible", true); + $("#content").append(`>>${quoted_post}\n`); + window.localStorage.removeItem("quoted_post"); + } + + $(window).on("setup_post_events", function (event) { + setup_events($(`#${event.id}`).find(".quote-link")); + }); + + setup_events($(".quote-link")); + + function setup_events(elements) { + elements.each(function () { + $(this).click(function () { + let post_id = $(this).text(); + let thread_url = $(this).attr("data-thread-url"); + let current_url = window.location.pathname; + + if (current_url !== thread_url) { + window.localStorage.setItem("quoted_post", post_id); + window.location.href = `${thread_url}#${post_id}`; + return false; + } + + $("#post-form").attr("data-visible", true); + $("#content").append(`>>${post_id}\n`); + + return false; + }); + }); + } +}); diff --git a/static/js/time.js b/static/js/time.js new file mode 100644 index 0000000..2da69b7 --- /dev/null +++ b/static/js/time.js @@ -0,0 +1,125 @@ +const MINUTE = 60000, + HOUR = 3600000, + DAY = 86400000, + WEEK = 604800000, + MONTH = 2592000000, + YEAR = 31536000000; + +$(function () { + $(window).on("setup_post_events", function (event) { + setup_events($(`#${event.id}`).find("time")); + }); + + setup_events($("time")); + + setInterval(() => { + setup_events($("time")); + }, 60000); + + function setup_events(elements) { + elements.each(function () { + let title = $(this).attr("title"); + + if (!title) { + $(this).attr("title", $(this).text()); + } + + let rel = reltime($(this).attr("datetime")); + + $(this).text(rel); + }); + } + + function reltime(date) { + let delta = Date.now() - Date.parse(date); + let fut = false; + + if (delta < 0) { + delta = Math.abs(delta); + fut = true; + } + + let minutes = Math.floor(delta / MINUTE); + let hours = Math.floor(delta / HOUR); + let days = Math.floor(delta / DAY); + let weeks = Math.floor(delta / WEEK); + let months = Math.floor(delta / MONTH); + let years = Math.floor(delta / YEAR); + + let rt = "Teď"; + + if (minutes > 0) { + if (fut) { + rt = `za ${minutes} ${plural("minutu|minuty|minut", minutes)}`; + } else { + rt = `před ${minutes} ${plural( + "minutou|minutami|minutami", + minutes + )}`; + } + } + + if (hours > 0) { + if (fut) { + rt = `za ${hours} ${plural("hodinu|hodiny|hodin", hours)}`; + } else { + rt = `před ${hours} ${plural( + "hodinou|hodinami|hodinami", + hours + )}`; + } + } + + if (days > 0) { + if (fut) { + rt = `za ${days} ${plural("den|dny|dnů", days)}`; + } else { + rt = `před ${days} ${plural("dnem|dny|dny", days)}`; + } + } + + if (weeks > 0) { + if (fut) { + rt = `za ${weeks} ${plural("týden|týdny", weeks)}`; + } else { + rt = `před ${weeks} ${plural("týdnem|týdny", weeks)}`; + } + } + + if (months > 0) { + if (fut) { + rt = `za ${months} ${plural("měsíc|měsíce|měsíců", months)}`; + } else { + rt = `před ${months} ${plural( + "měsícem|měsíci|měsíci", + months + )}`; + } + } + + if (years > 0) { + if (fut) { + rt = `za ${years} ${plural("rok|roky|let", years)}`; + } else { + rt = `před ${years} ${plural("rokem|lety|lety", years)}`; + } + } + + return rt; + } + + function plural(plurals, count) { + let plurals_arr = plurals.split("|"); + let one = plurals_arr[0]; + let few = plurals_arr[1]; + let other = plurals_arr[2]; + + if (count === 1) { + return one; + } else if (count < 5 && count !== 0) { + return few; + } else { + return other; + } + } +}); diff --git a/static/spoiler.png b/static/spoiler.png new file mode 100755 index 0000000..9eb148b Binary files /dev/null and b/static/spoiler.png differ diff --git a/static/style.css b/static/style.css new file mode 100755 index 0000000..42cc1c5 --- /dev/null +++ b/static/style.css @@ -0,0 +1,535 @@ +:root { + font-size: 10pt; + font-family: var(--font); + color: var(--text); +} + +body { + min-height: 100vh; + background: var(--bg); + margin: 0; +} + +a { + color: var(--link-color); +} + +a:hover { + color: var(--link-hover); +} + +details, +#live-info { + display: inline-block; + margin-bottom: 8px; +} + +details:last-of-type { + margin-bottom: 0; +} + +img, +video { + max-width: 100%; + max-height: 90vh; +} + +hr { + border-top: 1px solid var(--hr-color); + border-left: none; + border-right: none; + border-bottom: none; +} + +summary { + cursor: pointer; +} + +.form-table .label { + font-weight: bold; + background-color: var(--table-head); + border: 1px solid var(--table-border); + padding: 4px; +} + +.form-table td { + padding: 0; +} + +.container > form:not(#post-form) > .form-table { + margin: 8px auto; +} + +#post-form { + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; + + visibility: hidden; + position: fixed; + right: 0; + top: 0; + background-color: var(--box-color); + padding: 4px; +} + +#post-form:target, +#post-form[data-visible="true"] { + visibility: visible; +} + +#post-form-handle { + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + cursor: move; +} + +#post-form-handle::after { + content: ""; + display: block; + clear: right; +} + +.edit-box { + display: block; + width: 100%; +} + +.form-table input[type="text"], +.form-table input[type="password"], +.form-table input[type="number"], +.form-table textarea, +.form-table select, +.input-wrapper, +.edit-box { + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; + + display: block; + width: 100%; + color: var(--text); + background-color: var(--input-color); + border-radius: 0; + border: 1px solid var(--input-border); + padding: 4px; +} + +.form-table input[type="checkbox"] { + display: block; + margin: 0 auto; +} + +.form-table input[type="file"] { + width: 100%; +} + +.form-table textarea, +.edit-box { + height: 8rem; + resize: none; +} + +.table-wrap { + overflow: scroll; +} + +.data-table { + width: 100%; + border-spacing: 0; + border-collapse: collapse; + margin: 8px 0; +} + +.data-table th { + background-color: var(--table-head); +} + +.data-table td { + background-color: var(--table-background); +} + +.data-table td:not(.form-table td), +.data-table th { + border: 1px solid var(--table-border); + padding: 4px; +} + +.data-table .banner { + margin-top: 0; + margin-bottom: 0; +} + +.news { + margin: 8px 0; +} + +.box { + background-color: var(--box-color); + border-right: 1px solid var(--box-border); + border-bottom: 1px solid var(--box-border); + padding: 8px; +} + +.box:target, +.box.highlighted { + background-color: var(--hl-box-color); + border-right: 1px solid var(--hl-box-border); + border-bottom: 1px solid var(--hl-box-border); +} + +.button { + cursor: pointer; + border-radius: 0; + color: var(--text); + background-color: var(--input-color); + border: 1px solid var(--input-border); + padding: 4px; +} + +.main { + margin: 8px; +} + +.container { + margin: 0 auto; + max-width: 720px; +} + +.title { + color: var(--title-color); + font-family: var(--title-font); + text-align: center; + letter-spacing: -2px; + margin: 0; +} + +.description { + font-weight: bold; + margin: 0; +} + +.big { + font-size: 1.2rem; +} + +.small { + font-size: 0.8rem; +} + +.center { + text-align: center; +} + +.inline-block { + display: inline-block; +} + +.float-r { + float: right; +} + +.float-none-a, +.float-none-b { + float: none !important; +} + +.fixed-table { + table-layout: fixed; +} + +.m-0 { + margin: 0; +} + +.form-table .button, +.full-width { + width: 100%; +} + +.banner { + display: block; + width: 100%; + max-width: 300px; + margin: 8px auto; + border: 1px solid var(--box-border); +} + +.headline { + font-size: 1rem; + margin: 0; +} + +.headline::after { + content: ""; + display: block; + clear: both; +} + +.board-links, +.pagination { + color: var(--link-list-color); +} + +.link-separator::after { + content: " / "; +} + +.link-group::before { + content: " [ "; +} + +.link-group::after { + content: " ] "; +} + +.header { + padding: 2px; +} + +.header::after { + content: ""; + display: block; + clear: both; +} + +.footer { + text-align: center; + font-size: 8pt; + margin-top: 8px; +} + +.post { + margin-bottom: 8px; + padding: 8px; +} + +.post.box { + display: inline-block; + min-width: 400px; +} + +.post:last-of-type { + margin-bottom: 0; +} + +.board-links a, +.pagination a, +.post-number a { + text-decoration: none; +} + +.post-header input[type="checkbox"] { + height: 1em; + vertical-align: middle; + margin: 0; +} + +.catalog-entry { + display: inline-block; + width: 200px; + height: 250px; + overflow: scroll; + margin: 4px; + padding: 8px; +} + +.catalog-entry .thumb { + display: block; + max-width: 100%; + max-height: 50%; + box-shadow: 0 0 3px #000; + margin: 4px auto; + padding: 2px; +} + +.catalog-entry .post-content { + margin: 8px; +} + +.name { + font-weight: bold; + color: var(--name-color); +} + +.tripcode { + color: var(--trip-color); +} + +.capcode { + color: var(--capcode-color); + font-weight: bold; +} + +.user-id { + text-shadow: #000 0 0 1px, #000 0 0 1px, #000 0 0 1px, #000 0 0 1px, + #000 0 0 1px, #000 0 0 1px; + color: #ffffff; + border: 1px solid var(--box-border); + padding: 0 2px; +} + +.post-files { + float: left; + margin: 0 8px 8px 8px; +} + +.post-file { + display: inline-block; + vertical-align: top; + text-align: center; + font-size: 8pt; + padding: 4px; +} + +.thumb { + max-width: 150px; + max-height: 150px; +} + +.post-content { + font-family: inherit; + white-space: pre-wrap; + word-break: break-word; + margin: 0; +} + +.post-content a { + color: var(--post-link-color); +} + +.post-content a:hover { + color: var(--post-link-hover); +} + +.clearfix { + clear: both; +} + +.post .post-content { + margin: 1rem 2rem; +} + +.dead-quote { + color: var(--dead-quote-color); + text-decoration: line-through; +} + +.greentext { + color: var(--greentext-color); +} + +.orangetext { + color: var(--orangetext-color); +} + +.redtext { + color: var(--redtext-color); + font-weight: bold; +} + +.bluetext { + color: var(--bluetext-color); + font-weight: bold; +} + +.glowtext { + text-shadow: 0 0 40px #00fe20, 0 0 2px #00fe20; +} + +.uh-oh-text { + color: var(--uh-oh-text); + background-color: var(--uh-oh-color); +} + +.spoiler { + color: var(--text); + background-color: var(--text); +} + +.spoiler:hover { + background-color: transparent; +} + +.jannytext { + color: var(--jannytext-color); + font-weight: bold; +} + +.icon { + height: 0.8em; + vertical-align: middle; +} + +.posts-omitted { + margin-top: 0; + margin-bottom: 8px; +} + +.board-list { + list-style-type: none; + margin: 0; + padding: 0; +} + +@media only screen and (max-width: 600px) { + .thumb { + max-width: 100px; + max-height: 100px; + } + + .post.box { + display: block; + min-width: auto; + } + + .thread > br { + display: none; + } + + .catalog-entry { + width: 140px; + height: 220px; + } +} + +/* Only in JS */ + +.loading { + opacity: 0.5; + cursor: wait; +} + +#captcha { + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; + + display: block; + width: 100%; + height: 120px; + border: 1px solid var(--input-border); +} + +#captcha img { + display: block; + height: 100%; + image-rendering: pixelated; + margin: 0px auto; +} + +#live-indicator { + display: inline-block; + width: 0.8em; + height: 0.8em; + vertical-align: middle; + border-radius: 50%; +} + +#preview { + -webkit-box-shadow: 0px 0px 2.5px 2.5px rgba(0, 0, 0, 0.25); + -moz-box-shadow: 0px 0px 2.5px 2.5px rgba(0, 0, 0, 0.25); + box-shadow: 0px 0px 2.5px 2.5px rgba(0, 0, 0, 0.25); +} diff --git a/static/themes/yotsuba-b.css b/static/themes/yotsuba-b.css new file mode 100644 index 0000000..68efb52 --- /dev/null +++ b/static/themes/yotsuba-b.css @@ -0,0 +1,39 @@ +:root { + /* General */ + --bg: linear-gradient(#d1d5ee 3rem, #eef2ff 230px); + --text: #000000; + --font: Arial, Helvetica, sans-serif; + /* Text */ + --link-color: #34345c; + --link-hover: #dd0000; + --post-link-color: #34345c; + --post-link-hover: #dd0000; + --link-list-color: #8899aa; + --title-color: #af0a0f; + --title-font: tahoma; + --hr-color: #d3d3d3; + --name-color: #117743; + --trip-color: #117743; + --capcode-color: #cc1105; + /* Tables */ + --table-head: #9988ee; + --table-border: #000000; + --table-background: #ffffff; + /* Forms */ + --input-color: #ffffff; + --input-border: #808080; + /* Box™ */ + --box-color: #d6daf0; + --box-border: #b7c5d9; + --hl-box-color: #d6bad0; + --hl-box-border: #ba9dbf; + /* Formatting */ + --dead-quote-color: #2e2e2e; + --greentext-color: #008000; + --orangetext-color: #ff5100; + --redtext-color: #af0a0f; + --bluetext-color: #0000ff; + --uh-oh-color: #ffffff; + --uh-oh-text: #0038b8; + --jannytext-color: #ff0000; +} diff --git a/static/themes/yotsuba.css b/static/themes/yotsuba.css new file mode 100644 index 0000000..9402a17 --- /dev/null +++ b/static/themes/yotsuba.css @@ -0,0 +1,39 @@ +:root { + /* General */ + --bg: linear-gradient(#fed6af 3rem, #ffffee 230px); + --text: #800000; + --font: Arial, Helvetica, sans-serif; + /* Text */ + --link-color: #800000; + --link-hover: #dd0000; + --post-link-color: #000080; + --post-link-hover: #dd0000; + --link-list-color: #bb8866; + --title-color: #af0a0f; + --title-font: tahoma; + --hr-color: #d9bfb7; + --name-color: #117743; + --trip-color: #117743; + --capcode-color: #cc1105; + /* Tables */ + --table-head: #ffccaa; + --table-border: #800000; + --table-background: #ffffff; + /* Forms */ + --input-color: #ffffff; + --input-border: #808080; + /* Box™ */ + --box-color: #f0e0d6; + --box-border: #d9bfb7; + --hl-box-color: #f0c0b0; + --hl-box-border: #d99f91; + /* Formatting */ + --dead-quote-color: #2e2e2e; + --greentext-color: #008000; + --orangetext-color: #ff5100; + --redtext-color: #af0a0f; + --bluetext-color: #0000ff; + --uh-oh-color: #ffffff; + --uh-oh-text: #0038b8; + --jannytext-color: #ff0000; +} diff --git a/templates/action.html b/templates/action.html new file mode 100644 index 0000000..30fc3d3 --- /dev/null +++ b/templates/action.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} + +{% block title %}Výsledek{% endblock %} + +{% block content %} +
+

Výsledek

+ + + + + +
Výsledek
+ {% if !response.is_empty() %} + {{ response|linebreaksbr|safe }} + {% else %} + Nic se nezměnilo. + {% endif %} +
+
+{% endblock %} diff --git a/templates/banned.html b/templates/banned.html new file mode 100644 index 0000000..4f56b6b --- /dev/null +++ b/templates/banned.html @@ -0,0 +1,67 @@ +{% extends "base.html" %} + +{% block title %}Máš ban!{% endblock %} + +{% block content %} +
+

Zabanován!!!

+ + + + + +
Máš ban!
+ + Byl jsi zabanován + + {% if let Some(board) = ban.board %} + z /{{ board }}/ + {% else %} + ze všech nástěněk + {% endif %} + + z následujícího důvodu: + +
+ {{ ban.reason }} +
+ Udělil {{ ban.issued_by }} +
+
+ + Tvůj ban platí pro IP adresu/rozsah {{ ban.ip_range }} a + {% if let Some(expires) = ban.expires %} + vyprší + {% else %} + je trvalý. + {% endif %} + + {% if ban.appealable %} +
+
+ {% if let Some(appeal) = ban.appeal %} + Tvé odvolání: +
+ {{ appeal }} +
+ {% else %} + Můžeš se pokusit svůj ban odvolat: +
+ + + + + + + + + +
Odvolání
+ +
+
+ {% endif %} + {% endif %} +
+
+{% endblock %} diff --git a/templates/base.html b/templates/base.html new file mode 100755 index 0000000..8638622 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,63 @@ +{% import "./macros/board-links.html" as board_links %} + + + + + + + {% block title %}{% endblock %} + + + + + +
+ +
+ {% block content %}{% endblock %} + +
+
+ + + + + + + {% block scripts %}{% endblock %} + + diff --git a/templates/board-catalog.html b/templates/board-catalog.html new file mode 100755 index 0000000..e4b3657 --- /dev/null +++ b/templates/board-catalog.html @@ -0,0 +1,42 @@ +{% import "./macros/catalog-entry.html" as catalog_entry %} +{% import "./macros/post-actions.html" as post_actions %} + +{% extends "base.html" %} + +{% block theme %}{{ board.config.0.board_theme }}{% endblock %} +{% block title %}Katalog (/{{ board.id }}/){% endblock %} + +{% block content %} +
+
+ +

Katalog (/{{ board.id }}/)

+

{{ board.description }}

+ Index +
+
+
+
+ + + + + + +
+ + + +
+
+
+
+
+ {% for thread in threads %} + {% call catalog_entry::catalog_entry(thread, loop.index, board.config.0.page_size.into()) %} + {% endfor %} +
+
+ {% call post_actions::post_actions() %} +
+{% endblock %} diff --git a/templates/board.html b/templates/board.html new file mode 100755 index 0000000..6087069 --- /dev/null +++ b/templates/board.html @@ -0,0 +1,58 @@ +{% import "./macros/pagination.html" as pagination %} +{% import "./macros/post-actions.html" as post_actions %} +{% import "./macros/post-form.html" as post_form %} +{% import "./macros/post.html" as post %} + +{% extends "base.html" %} + +{% block theme %}{{ board.config.0.board_theme }}{% endblock %} +{% block title %}/{{ board.id }}/ - {{ board.name }}{% endblock %} +{% block scripts %} + + +{% endblock %} + +{% block content %} +
+
+ +

/{{ board.id }}/ - {{ board.name }}

+

{{ board.description }}

+ Katalog +
+
+ [Nové vlákno] +
+
+
+ {% call post_form::post_form(board, false, 0) %} +
+
+
+ {% for (thread, replies) in threads %} +
+ {% call post::post(board, thread, false) %} + {% let count = replies.len() %} + {% if count > 5 %} +

+ {% let omitted = count - 5 %} + {{ "Vynechán|Vynechány|Vynecháno"|czech_plural(omitted) }} {{ omitted }} {{ "příspěvek|příspěvky|příspěvků"|czech_plural(omitted) }}. Zobrazit celé vlákno. +

+ {% for reply_post in replies.iter().rev().take(5).rev() %} + {% call post::post(board, reply_post, true) %} +
+ {% endfor %} + {% else %} + {% for reply_post in replies %} + {% call post::post(board, reply_post, true) %} +
+ {% endfor %} + {% endif %} +
+
+ {% endfor %} + {% call pagination::pagination("/boards/{}"|format(board.id), pages, page) %} +
+ {% call post_actions::post_actions(tcx.perms) %} +
+{% endblock %} diff --git a/templates/edit-posts.html b/templates/edit-posts.html new file mode 100644 index 0000000..7c30598 --- /dev/null +++ b/templates/edit-posts.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} + +{% block title %}Upravit příspěvky{% endblock %} + +{% block content %} +
+

Upravit příspěvky

+
+
+ {% for post in posts %} +
+ Příspěvek #{{ post.id }} na /{{ post.board }}/ + +
+
+ {% endfor %} + +
+
+{% endblock %} diff --git a/templates/error.html b/templates/error.html new file mode 100755 index 0000000..28efc34 --- /dev/null +++ b/templates/error.html @@ -0,0 +1,31 @@ + + + + + + Chyba + {# Error pages are yotsuba (they just are, okay?) #} + {# Go ahead and edit this manually if you actually care #} + + + + +
+
+
+

Je konec...

+ + + + {% if !error_message.is_empty() %} + + {% endif %} +
Chyba {{ error_code }}
{{ error_message }}
+
+ +
+
+ + diff --git a/templates/index.html b/templates/index.html new file mode 100755 index 0000000..7538a8d --- /dev/null +++ b/templates/index.html @@ -0,0 +1,65 @@ +{% import "./macros/board-links.html" as board_links %} + +{% extends "base.html" %} + +{% block title %}{{ tcx.cfg.site.name }}{% endblock %} + +{% block content %} +
+
+

{{ tcx.cfg.site.name }}

+

{{ tcx.cfg.site.description }}

+ +
+ {% if let Some(news) = news %} + + + + + + + + + + +
Novinky
+
+

+ {{ news.title }} + + {{ news.author }} - + +

+
+
{{ news.content|safe }}
+
+
+ Zobrazit všechny novinky... +
+ {% endif %} + + + + + + + + + +
NástěnkyStatistika
+ + + {% let board_count = tcx.boards.len() %} + Celkem {{ "byl vytvořen|byly vytvořeny|bylo vytvořeno"|czech_plural(stats.post_count) }} + {{ stats.post_count }} {{ "příspěvek|příspěvky|příspěvků"|czech_plural(stats.post_count) }} + na {{ board_count }} {{ "nástěnce|nástěnkách|nástěnkách"|czech_plural(board_count) }}. +
+ Aktuálně {{ "je nahrán|jsou nahrány|je nahráno"|czech_plural(stats.file_count) }} {{ stats.file_count }} + {{ "soubor|soubory|souborů"|czech_plural(stats.file_count) }}, celkem {{ stats.file_size|filesizeformat }}. +
+
+{% endblock %} diff --git a/templates/ip-posts.html b/templates/ip-posts.html new file mode 100644 index 0000000..9753ea4 --- /dev/null +++ b/templates/ip-posts.html @@ -0,0 +1,29 @@ +{% import "./macros/post-actions.html" as post_actions %} +{% import "./macros/post.html" as post %} +{% import "./macros/static-pagination.html" as static_pagination %} + +{% extends "base.html" %} + +{% block title %}Příspěvky od [{{ ip }}]{% endblock %} + +{% block content %} +
+ +

Příspěvky od [{{ ip }}]

+
+
+
+
+ {% for post in posts %} + Příspěvek z /{{ post.board }}/ +
+ {% call post::post(boards[post.board.as_str()], post, true) %} +
+ {% endfor %} +
+
+ {% call static_pagination::static_pagination("/ip-posts/{}"|format(ip), page, false) %} +
+ {% call post_actions::post_actions() %} +
+{% endblock %} diff --git a/templates/login.html b/templates/login.html new file mode 100755 index 0000000..7f4d12a --- /dev/null +++ b/templates/login.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} + +{% block title %}Přihlásit se{% endblock %} + +{% block content %} +
+

Přihlásit se

+
+ + + + + + + + + + + + +
Jméno
Heslo
+
+
+{% endblock %} diff --git a/templates/macros/board-links.html b/templates/macros/board-links.html new file mode 100644 index 0000000..1899c21 --- /dev/null +++ b/templates/macros/board-links.html @@ -0,0 +1,8 @@ +{% macro board_links() %} + + {% for board_link in tcx.boards %} + {{ board_link }} + {% if !loop.last %}{% endif %} + {% endfor %} + +{% endmacro %} diff --git a/templates/macros/catalog-entry.html b/templates/macros/catalog-entry.html new file mode 100644 index 0000000..596d17d --- /dev/null +++ b/templates/macros/catalog-entry.html @@ -0,0 +1,23 @@ +{% macro catalog_entry(post, index, page_size) %} +
+ + /{{ post.board }}/{{ post.id }} + {% if let Some(file) = post.files.0.get(0) %} + + + + {% else %} +

[Link]

+ {% endif %} + + R: {{ post.replies }} / P: {{ index|get_page(page_size) }} + {% if post.sticky %} + + {% endif %} + {% if post.locked %} + + {% endif %} + +
{{ post.content|add_yous(post.board, tcx.yous)|safe }}
+
+{% endmacro %} diff --git a/templates/macros/pagination.html b/templates/macros/pagination.html new file mode 100644 index 0000000..6117257 --- /dev/null +++ b/templates/macros/pagination.html @@ -0,0 +1,25 @@ +{% macro pagination(base, pages, current) %} + +{% endmacro %} diff --git a/templates/macros/post-actions.html b/templates/macros/post-actions.html new file mode 100644 index 0000000..5da477f --- /dev/null +++ b/templates/macros/post-actions.html @@ -0,0 +1,224 @@ +{% macro post_actions() %} +
+ Uživatelské akce + + + + + + + + + + + + + + + + + + + + + +
Odstranit příspěvky +
+ +
+
Odstranit soubory +
+ +
+
Přidat/odstranit spoiler +
+ +
+
Heslo
+ +
+ + + + + + + + +
Důvod hlášení
+ +
+
+
+{% if tcx.perms.owner() || tcx.perms.edit_posts() || tcx.perms.manage_posts() || tcx.perms.reports() || tcx.perms.bans() %} +
+ Uklízečské akce + + {% if tcx.perms.owner() || tcx.perms.manage_posts() %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% endif %} + {% if tcx.perms.owner() || tcx.perms.reports() %} + + + + + {% endif %} + {% if tcx.perms.owner() || tcx.perms.bans() %} + + + + + {% if tcx.perms.owner() || tcx.perms.reports() %} + + + + + {% endif %} + + + + + + + + + + + + + + + + + + + + + {% endif %} + {% if tcx.perms.owner() || (tcx.perms.edit_posts() && tcx.perms.view_ips()) %} + + + {% endif %} + + + +
Odstranit příspěvky +
+ +
+
Odstranit soubory +
+ +
+
Přidat/odstranit spoiler +
+ +
+
Odstranit od IP na nástěnce +
+ +
+
Odstranit od IP globálně +
+ +
+
Připnout/odepnout +
+ +
+
Uzamknout/odemknout +
+ +
+
Odstranit hlášení +
+ +
+
Zabanovat uživatele +
+ +
+
Zabanovat nahlašovatele +
+ +
+
Globální ban +
+ +
+
Neodvolatelný ban +
+ +
+
Důvod banu
Délka banu (dny, 0 = trvalý)
Rozsah banu + +
Vytrolit uživatele +
+ +
+
+ +
+ {% if tcx.perms.owner() || tcx.perms.edit_posts() %} + + + + +
+ +
+ {% endif %} +
+{% endif %} +{% endmacro %} diff --git a/templates/macros/post-form.html b/templates/macros/post-form.html new file mode 100644 index 0000000..1d1e3ae --- /dev/null +++ b/templates/macros/post-form.html @@ -0,0 +1,105 @@ +{% macro post_form(board, reply, reply_to) %} +
+ + {% if reply %} + + {% endif %} + + + + + + + + + + + + + + + + + {% if !(tcx.perms.bypass_captcha() || tcx.perms.owner()) %} + {% let difficulty %} + {% if reply %} + {% let difficulty = board.config.0.reply_captcha.as_str() %} + {% else %} + {% let difficulty = board.config.0.thread_captcha.as_str() %} + {% endif %} + + {% if (!reply && board.config.0.thread_captcha != "off") || (reply && board.config.0.reply_captcha != "off") %} + + + + + {% endif %} + {% endif %} + + + + + + + + + + + + + + {% if reply %} + + {% else %} + + {% endif %} + +
+ {% if reply %} + Nová odpověď + {% else %} + Nové vlákno + {% endif %} + [X] +
Jméno + +
Email + +
Obsah + +
+ CAPTCHA
+ (vyprší za 10 min.) + +
+
+ + + + + + + +
+ + + +
+
+
+ {% if board.config.0.file_limit > 1 %} + Soubory (max {{ board.config.0.file_limit }}) + {% else %} + Soubor + {% endif %} + +
+ 1 %} multiple="multiple"{% endif %}{% if (!reply && board.config.0.require_thread_file) || (reply && board.config.0.require_reply_file) %} required=""{% endif %}> +
+
Spoiler? +
+ +
+
Heslo
+
+{% endmacro %} diff --git a/templates/macros/post.html b/templates/macros/post.html new file mode 100644 index 0000000..5c77062 --- /dev/null +++ b/templates/macros/post.html @@ -0,0 +1,77 @@ +{% macro post(board, post, boxed) %} +
+
+ + {% if tcx.perms.owner() || tcx.perms.view_ips() %} + [+] + {% endif %} + {% if let Some(email) = post.email %} + {{ post.name }} + {% else %} + {{ post.name }} + {% endif %} + {% if let Some(tripcode) = post.tripcode %} + {{ tripcode }} + {% endif %} + {% if let Some(capcode) = post.capcode %} + ## {{ capcode }} + {% endif %} + {% if tcx.ip == post.ip %} + {# Technically not a tripcode or something but same styling #} + (Ty) + {% endif %} + {% if board.config.0.flags %} + + {% endif %} + + {% if board.config.0.user_ids %} + {{ post.user_id }} + {% endif %} + + Č. + {{ post.id }} + + {% if post.sticky %} + + {% endif %} + {% if post.locked %} + + {% endif %} + + {% if !boxed %} + [Otevřít] + {% endif %} +
+ {% if !post.files.0.is_empty() %} +
+ {% for file in post.files.0 %} +
+ + {% if file.spoiler %} + [Spoiler] + {% else %} + {{ file.original_name|truncate(20) }} + {% endif %} + +
+ ({{ file.size|filesizeformat }}, {{ file.width }}x{{ file.height }}) +
+ +
+ {% endfor %} +
+ {% endif %} +
{{ post.content|add_yous(post.board, tcx.yous)|safe }}
+
+ {% if !post.quotes.is_empty() %} +
+ Odpovědi: + {% for quote in post.quotes %} + >>{{ quote }} + {% endfor %} +
+ {% endif %} +
+{% endmacro %} diff --git a/templates/macros/staff-nav.html b/templates/macros/staff-nav.html new file mode 100644 index 0000000..b05c372 --- /dev/null +++ b/templates/macros/staff-nav.html @@ -0,0 +1,26 @@ +{% macro staff_nav() %} + +{% endmacro %} diff --git a/templates/macros/static-pagination.html b/templates/macros/static-pagination.html new file mode 100644 index 0000000..4ef0118 --- /dev/null +++ b/templates/macros/static-pagination.html @@ -0,0 +1,22 @@ +{% macro static_pagination(base, current, chain) %} + +{% endmacro %} diff --git a/templates/news.html b/templates/news.html new file mode 100644 index 0000000..36a6a6e --- /dev/null +++ b/templates/news.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} + +{% block title %}Novinky{% endblock %} + +{% block content %} +
+
+ +

Novinky

+
+ {% for newspost in news %} +
+

+ {{ newspost.title }} + + {{ newspost.author }} - + +

+
+
{{ newspost.content|safe }}
+
+ {% endfor %} +
+{% endblock %} \ No newline at end of file diff --git a/templates/overboard-catalog.html b/templates/overboard-catalog.html new file mode 100755 index 0000000..0ac4dd6 --- /dev/null +++ b/templates/overboard-catalog.html @@ -0,0 +1,40 @@ +{% import "./macros/catalog-entry.html" as catalog_entry %} +{% import "./macros/post-actions.html" as post_actions %} + +{% extends "base.html" %} + +{% block title %}Katalog nadnástěnky{% endblock %} + +{% block content %} +
+
+ +

Katalog nadnástěnky

+

Nově naťuknutá vlákna ze všech nástěnek

+ Index +
+
+
+
+ + + + + +
+ + + +
+
+
+
+
+ {% for thread in threads %} + {% call catalog_entry::catalog_entry(thread, loop.index, crate::GENERIC_PAGE_SIZE) %} + {% endfor %} +
+
+ {% call post_actions::post_actions() %} +
+{% endblock %} diff --git a/templates/overboard.html b/templates/overboard.html new file mode 100644 index 0000000..d31a652 --- /dev/null +++ b/templates/overboard.html @@ -0,0 +1,47 @@ +{% import "./macros/pagination.html" as pagination %} +{% import "./macros/post-actions.html" as post_actions %} +{% import "./macros/post.html" as post %} + +{% extends "base.html" %} + +{% block title %}Index nadnástěnky{% endblock %} + +{% block content %} +
+
+ +

Index nadnástěnky

+

Nově naťuknutá vlákna ze všech nástěnek

+ Katalog +
+
+
+
+ {% for (thread, replies) in threads %} +
+ Vlákno z /{{ thread.board }}/ + {% call post::post(boards[thread.board.as_str()], thread, false) %} + {% let count = replies.len() %} + {% if count > 5 %} +

+ {% let omitted = count - 5 %} + {{ "Vynechán|Vynechány|Vynecháno"|czech_plural(omitted) }} {{ omitted }} {{ "příspěvek|příspěvky|příspěvků"|czech_plural(omitted) }}. Zobrazit celé vlákno. +

+ {% for reply_post in replies.iter().rev().take(5).rev() %} + {% call post::post(boards[reply_post.board.as_str()], reply_post, true) %} +
+ {% endfor %} + {% else %} + {% for reply_post in replies %} + {% call post::post(boards[reply_post.board.as_str()], reply_post, true) %} +
+ {% endfor %} + {% endif %} +
+
+ {% endfor %} + {% call pagination::pagination("/overboard", pages, page) %} +
+ {% call post_actions::post_actions() %} +
+{% endblock %} diff --git a/templates/page.html b/templates/page.html new file mode 100644 index 0000000..beb6595 --- /dev/null +++ b/templates/page.html @@ -0,0 +1,3 @@ +{% extends "base.html" %} +{% block title %}{{ tcx.cfg.site.name }} ({{ name }}){% endblock %} +{% block content %}{{ content|safe }}{% endblock %} diff --git a/templates/search.html b/templates/search.html new file mode 100644 index 0000000..b958392 --- /dev/null +++ b/templates/search.html @@ -0,0 +1,47 @@ +{% import "./macros/post-actions.html" as post_actions %} +{% import "./macros/post.html" as post %} +{% import "./macros/static-pagination.html" as static_pagination %} + +{% extends "base.html" %} + +{% block title %} +Vyhledávání ({% if let Some(board) = board_opt %}/{{ board.id }}/{% else %}nadnástěnka{% endif %}) +{% endblock %} + +{% block content %} +
+ +

+ Výsledky pro "{{ query }}" ( + {% if let Some(board) = board_opt %} + /{{ board.id }}/ + {% else %} + nadnástěnka + {% endif %} + ) +

+
+
+
+
+ {% for post in posts %} + {% if let Some(board) = board_opt %} + {% call post::post(board, post, true) %} + {% else %} + Příspěvek z /{{ post.board }}/ +
+ {% call post::post(boards[post.board.as_str()], post, true) %} + {% endif %} +
+ {% endfor %} +
+
+ {% if let Some(board) = board_opt %} + {% call static_pagination::static_pagination("/search?board={}&query={}"|format(board.id, query|urlencode_strict), page, true) %} + {% else %} + {% call static_pagination::static_pagination("/search?query={}"|format(query|urlencode_strict), page, true) %} + {% endif %} +
+ {% call post_actions::post_actions() %} +
+{% endblock %} diff --git a/templates/staff/account.html b/templates/staff/account.html new file mode 100755 index 0000000..2e67809 --- /dev/null +++ b/templates/staff/account.html @@ -0,0 +1,67 @@ +{% import "../macros/staff-nav.html" as staff_nav %} + +{% extends "base.html" %} + +{% block title %}Účet ({{ account.username }}){% endblock %} + +{% block content %} +

Účet ({{ account.username }})

+{% call staff_nav::staff_nav() %} +
+

Změnit heslo

+
+ + + + + + + + + + + + +
Staré heslo
Nové heslo
+
+
+{% if tcx.perms.owner() %} +

Předat vlastnictví

+
+ + + + + + + + + + + + +
Účet
Potvrdit +
+ +
+
+
+
+{% endif %} +

Vymazat účet

+
+ + + + + + + + +
Potvrdit +
+ +
+
+
+{% endblock %} diff --git a/templates/staff/accounts.html b/templates/staff/accounts.html new file mode 100755 index 0000000..f898bb6 --- /dev/null +++ b/templates/staff/accounts.html @@ -0,0 +1,56 @@ +{% import "../macros/staff-nav.html" as staff_nav %} + +{% extends "base.html" %} + +{% block title %}Účty{% endblock %} + +{% block content %} +

Účty

+{% call staff_nav::staff_nav() %} +
+

Účty

+
+
+ + + + + + + + + {% for account in accounts %} + + + + + + + + {% endfor %} +
JménoVlastníkVytvořenOprávnění
{{ account.username }}{% if account.owner %}Ano{% else %}Ne{% endif %}{{ account.permissions.0 }} [Zobrazit]
+
+ {% if tcx.perms.owner() %} + + {% endif %} +
+{% if tcx.perms.owner() %} +
+

Vytvořit účet

+
+ + + + + + + + + + + + +
Jméno
Heslo
+
+{% endif %} +{% endblock %} diff --git a/templates/staff/banners.html b/templates/staff/banners.html new file mode 100755 index 0000000..1da830b --- /dev/null +++ b/templates/staff/banners.html @@ -0,0 +1,45 @@ +{% import "../macros/staff-nav.html" as staff_nav %} + +{% extends "base.html" %} + +{% block title %}Bannery{% endblock %} + +{% block content %} +

Bannery

+{% call staff_nav::staff_nav() %} +
+

Bannery

+
+
+ + + + + + {% for banner in banners %} + + + + + {% endfor %} +
Banner
+ +
+
+ + +
+
+

Přidat bannery

+
+ + + + + + + + +
Bannery
+
+{% endblock %} diff --git a/templates/staff/bans.html b/templates/staff/bans.html new file mode 100755 index 0000000..602a189 --- /dev/null +++ b/templates/staff/bans.html @@ -0,0 +1,54 @@ +{% import "../macros/staff-nav.html" as staff_nav %} + +{% extends "base.html" %} + +{% block title %}Bany{% endblock %} + +{% block content %} +

Bany

+{% call staff_nav::staff_nav() %} +
+

Bany

+
+
+ + + + + + + + + + + + + {% for ban in bans %} + + + + + + + + + + {% if let Some(expires) = ban.expires %} + + {% else %} + + {% endif %} + + {% endfor %} +
IPNástěnkaDůvodUdělilOdvolatelnýOdvoláníUdělěnVyprší
+ {% if ban.ip_range.network() == ban.ip_range.broadcast() %} + {{ ban.ip_range.ip() }} + {% else %} + {{ ban.ip_range.network() }}-{{ ban.ip_range.broadcast() }} + {% endif %} + {% if let Some(board) = ban.board %}/{{ board }}/{% else %}Všechny{% endif %}
{{ ban.reason }}
{{ ban.issued_by }}{% if ban.appealable %}Ano{% else %}Ne{% endif %}{% if let Some(appeal) = ban.appeal %}
{{ appeal }}
{% else %}-{% endif %}
Nikdy
+
+ + +
+{% endblock %} diff --git a/templates/staff/board-config.html b/templates/staff/board-config.html new file mode 100755 index 0000000..6946050 --- /dev/null +++ b/templates/staff/board-config.html @@ -0,0 +1,187 @@ +{% import "../macros/staff-nav.html" as staff_nav %} + +{% extends "base.html" %} + +{% block title %}Nastavení (/{{ board.id }}/){% endblock %} + +{% block content %} +

Nastavení (/{{ board.id }}/)

+{% call staff_nav::staff_nav() %} +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Uzamknout nástěnku +
+ +
+
Výchozí jméno
Velikost stránky
Počet stránek
Limit souborů
Limit naťuknutí
Limit odpovědí
Identifikátory (anti-samefag) +
+ +
+
Vlajky +
+ +
+
CAPTCHA (vlákno) + +
CAPTCHA (odpověď) + +
Motiv nástěnky
Vyžadovat obsah ve vlákně +
+ +
+
Vyžadovat soubor ve vlákně +
+ +
+
Vyžadovat obsah v odpovědi +
+ +
+
Vyžadovat soubor v odpovědi +
+ +
+
Antispam +
+ +
+
Interval antispamu (IP)
Interval antispamu (Obsah)
Interval antispamu (IP+Obsah)
Interval mezi vlákny (IP)
+
+{% endblock %} diff --git a/templates/staff/boards.html b/templates/staff/boards.html new file mode 100755 index 0000000..935adb4 --- /dev/null +++ b/templates/staff/boards.html @@ -0,0 +1,75 @@ +{% import "../macros/staff-nav.html" as staff_nav %} + +{% extends "base.html" %} + +{% block title %}Nástěnky{% endblock %} + +{% block content %} +

Nástěnky

+{% call staff_nav::staff_nav() %} +
+

Nástěnky

+
+
+ + + + + + + + + + {% for board in boards %} + + + + + + + + + {% endfor %} +
IDJménoPopisVytvořenaNastavení
/{{ board.id }}/{{ board.name }}{{ board.description }}{% if tcx.perms.owner() || tcx.perms.board_config() %}[Zobrazit]{% else %}-{% endif %}
+
+ + {% if tcx.perms.owner() %} + +
+ + + + + + + + + + + + +
Jméno
Popis
+ {% endif %} +
+
+

Vytvořit nástěnku

+
+ + + + + + + + + + + + + + + + +
ID
Jméno
Popis
+
+{% endblock %} diff --git a/templates/staff/edit-news.html b/templates/staff/edit-news.html new file mode 100644 index 0000000..1c8104e --- /dev/null +++ b/templates/staff/edit-news.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} + +{% block title %}Upravit příspěvky{% endblock %} + +{% block content %} +
+

Upravit novinky

+
+
+ {% for newspost in news %} +
+

+ {{ newspost.title }} + + {{ newspost.author }} - + +

+
+ +
+
+ {% endfor %} + +
+
+{% endblock %} diff --git a/templates/staff/news.html b/templates/staff/news.html new file mode 100644 index 0000000..a8c5c42 --- /dev/null +++ b/templates/staff/news.html @@ -0,0 +1,51 @@ +{% import "../macros/staff-nav.html" as staff_nav %} + +{% extends "base.html" %} + +{% block title %}Novinky{% endblock %} + +{% block content %} +

Novinky

+{% call staff_nav::staff_nav() %} +
+

Novinky

+
+
+ + + + + + + + {% for newspost in news %} + + + + + + + {% endfor %} +
TitulekAutorDatum
{{ newspost.title }}{{ newspost.author }}
+
+ + +
+
+

Vytvořit novinky

+
+ + + + + + + + + + + + +
Titulek
Obsah
+
+{% endblock %} diff --git a/templates/staff/permissions.html b/templates/staff/permissions.html new file mode 100755 index 0000000..842df3c --- /dev/null +++ b/templates/staff/permissions.html @@ -0,0 +1,179 @@ +{% import "../macros/staff-nav.html" as staff_nav %} + +{% extends "base.html" %} + +{% block title %}Oprávnění ({{ account.username }}){% endblock %} + +{% block content %} +

Oprávnění ({{ account.username }})

+{% call staff_nav::staff_nav() %} +
+{% if account.perms().owner() %} +

Tento uživatel je vlastník, změny nebudou mít žádný vliv.

+
+{% endif %} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% if tcx.perms.owner() %} + + + + {% endif %} +
Upravit příspěvky +
+ +
+
Spravovat příspěvky +
+ +
+
Používat capcode +
+ +
+
Vlastní capcode +
+ +
+
Záznamy +
+ +
+
Hlášení +
+ +
+
Bany +
+ +
+
Bannery +
+ +
+
Nastavení nástěnek +
+ +
+
Novinky +
+ +
+
Uklízečtext (pravý redtext) +
+ +
+
Zobrazit IP adresy +
+ +
+
Obejít ban +
+ +
+
Obejít uzamčení nástěnky +
+ +
+
Obejít uzamčení vlákna +
+ +
+
Obejít CAPTCHA +
+ +
+
Obejít antispam +
+ +
+
+
+{% endblock %} diff --git a/templates/staff/reports.html b/templates/staff/reports.html new file mode 100755 index 0000000..bb643e9 --- /dev/null +++ b/templates/staff/reports.html @@ -0,0 +1,40 @@ +{% import "../macros/post-actions.html" as post_actions %} +{% import "../macros/post.html" as post %} +{% import "../macros/staff-nav.html" as staff_nav %} +{% import "../macros/static-pagination.html" as static_pagination %} + +{% extends "base.html" %} + +{% block title %}Hlášení{% endblock %} + +{% block content %} +
+

Hlášení

+

>Ukliď to!!!

+
+{% call staff_nav::staff_nav() %} +
+
+ {% for post in posts %} + Příspěvek z /{{ post.board }}/ +
+ {% call post::post(boards[post.board.as_str()], post, true) %} + + + + + + {% for report in post.reports.0 %} + + + + + {% endfor %} +
IP adresaDůvod hlášení
{{ report.reporter_ip }} (){{ report.reason }}
+
+ {% endfor %} + {% call static_pagination::static_pagination("/staff/reports", page, false) %} +
+ {% call post_actions::post_actions() %} +
+{% endblock %} diff --git a/templates/thread.html b/templates/thread.html new file mode 100644 index 0000000..5c715b9 --- /dev/null +++ b/templates/thread.html @@ -0,0 +1,55 @@ +{% import "./macros/post-actions.html" as post_actions %} +{% import "./macros/post-form.html" as post_form %} +{% import "./macros/post.html" as post %} + +{% extends "base.html" %} + +{% block theme %}{{ board.config.0.board_theme }}{% endblock %} +{% block title %}/{{ board.id }}/ - {{ thread.content_nomarkup|inline_post }}{% endblock %} + +{% block scripts %} + + + +{% endblock %} + +{% block content %} +
+
+ +

/{{ board.id }}/ - {{ board.name }}

+

{{ board.description }}

+ Katalog +
+
+ [Nová odpověď] +
+
+
+ {% call post_form::post_form(board, true, thread.id) %} +
+
+ +
+
+
+ {% call post::post(board, thread, false) %} + {% for reply_post in replies %} + {% call post::post(board, reply_post, true) %} +
+ {% endfor %} +
+
+ +
+ {% call post_actions::post_actions() %} +
+{% endblock %}