From e90dfa0b0b0aad48c1c5c53bcfb8da234f5064b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Mekina?= Date: Fri, 2 May 2025 13:03:30 +0200 Subject: [PATCH] add basic question uploading --- Cargo.lock | 389 ++++++++++++++++++++++++++++++++++- Makefile | 6 +- client/index.html | 7 +- config/dev.toml | 10 + config/make.mk | 1 + theseus-server/Cargo.toml | 2 + theseus-server/askama.toml | 2 + theseus-server/src/config.rs | 15 +- theseus-server/src/logger.rs | 5 + theseus-server/src/main.rs | 262 ++++++++++++++++++++--- 10 files changed, 656 insertions(+), 43 deletions(-) create mode 100644 theseus-server/askama.toml diff --git a/Cargo.lock b/Cargo.lock index 8853099..41bc17e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -32,6 +32,48 @@ dependencies = [ "libc", ] +[[package]] +name = "askama" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75363874b771be265f4ffe307ca705ef6f3baa19011c149da8674a87f1b75c4" +dependencies = [ + "askama_derive", + "itoa", + "percent-encoding", + "serde", + "serde_json", +] + +[[package]] +name = "askama_derive" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "129397200fe83088e8a68407a8e2b1f826cf0086b21ccdb866a722c8bcd3a94f" +dependencies = [ + "askama_parser", + "basic-toml", + "memchr", + "proc-macro2", + "quote", + "rustc-hash", + "serde", + "serde_derive", + "syn", +] + +[[package]] +name = "askama_parser" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6ab5630b3d5eaf232620167977f95eb51f3432fc76852328774afbd242d4358" +dependencies = [ + "memchr", + "serde", + "serde_derive", + "winnow", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -59,6 +101,15 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "2.9.0" @@ -79,9 +130,9 @@ checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" [[package]] name = "cc" -version = "1.2.20" +version = "1.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04da6a0d40b948dfc4fa8f5bbf402b0fc1a64a28dbf7d12ffd683550f2c1b63a" +checksum = "8691782945451c1c383942c4874dbe63814f61cb57ef773cda2972682b7bb3c0" dependencies = [ "shlex", ] @@ -112,6 +163,17 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -124,6 +186,15 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -190,9 +261,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.15.2" +version = "0.15.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" +checksum = "84b26c544d002229e640969970a2e74021aadf6e2f96372b9c58eff97de08eb3" [[package]] name = "http" @@ -305,6 +376,145 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_collections" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locid" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locid_transform" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_locid_transform_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locid_transform_data" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7515e6d781098bf9f7205ab3fc7e9709d34554ae0b21ddbcb5febfa4bc7df11d" + +[[package]] +name = "icu_normalizer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "write16", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e8338228bdc8ab83303f16b797e177953730f601a96c25d10cb3ab0daa0cb7" + +[[package]] +name = "icu_properties" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locid_transform", + "icu_properties_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85fb8799753b75aee8d2a21d7c14d9f38921b54b3dbda10f5a3c7a7b82dba5e2" + +[[package]] +name = "icu_provider" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_provider_macros", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_provider_macros" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "idna" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.9.0" @@ -337,6 +547,12 @@ version = "0.2.172" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" +[[package]] +name = "litemap" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" + [[package]] name = "lock_api" version = "0.4.12" @@ -426,6 +642,12 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -471,12 +693,24 @@ version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustversion" version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + [[package]] name = "scopeguard" version = "1.2.0" @@ -507,6 +741,18 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_json" +version = "1.0.140" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + [[package]] name = "serde_spanned" version = "0.6.8" @@ -556,6 +802,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + [[package]] name = "syn" version = "2.0.101" @@ -567,10 +819,22 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "theseus-server" version = "0.1.0" dependencies = [ + "askama", "chrono", "http-body-util", "hyper", @@ -578,6 +842,17 @@ dependencies = [ "serde", "tokio", "toml", + "url", +] + +[[package]] +name = "tinystr" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +dependencies = [ + "displaydoc", + "zerovec", ] [[package]] @@ -700,6 +975,29 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +[[package]] +name = "url" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "want" version = "0.3.1" @@ -907,9 +1205,88 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" -version = "0.7.7" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb8234a863ea0e8cd7284fcdd4f145233eb00fee02bbdd9861aec44e6477bc5" +checksum = "9e27d6ad3dac991091e4d35de9ba2d2d00647c5d0fc26c5496dee55984ae111b" dependencies = [ "memchr", ] + +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + +[[package]] +name = "writeable" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" + +[[package]] +name = "yoke" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerovec" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Makefile b/Makefile index ccad206..34c3c83 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,9 @@ SRCS_RUST_THESEUS_SERVER := $(shell find theseus-server -type f) build: \ dst/release/theseus-server +include config/make.mk +include client/make.mk + .PHONY: run run: dst/dev.toml $(TARGETS_CLIENT) cargo run --package theseus-server -- dst/dev.toml @@ -12,9 +15,6 @@ run: dst/dev.toml $(TARGETS_CLIENT) clean: client_clean rm -rf dst -include config/make.mk -include client/make.mk - dst/release/theseus-server: $(SRCS_RUST_THESEUS_SERVER) $(TARGETS_CLIENT) cargo build --package theseus-server --release touch $@ diff --git a/client/index.html b/client/index.html index db6ced0..1fe3fd1 100644 --- a/client/index.html +++ b/client/index.html @@ -10,9 +10,14 @@

Ask Stallman

+ {%- if message.len() != 0 -%} +
+

{{ message }}

+
+ {%- endif -%}
- +
diff --git a/config/dev.toml b/config/dev.toml index 8bd70fc..05c4885 100644 --- a/config/dev.toml +++ b/config/dev.toml @@ -1,2 +1,12 @@ [server] bind_to = "[::1]:8080" +max_question_body_size = 25 + +[performance] +memory_limit = 50 + +[maintenance] +interval = 60 + +[push] +endpoint = "[::1]:8081" diff --git a/config/make.mk b/config/make.mk index 2a7ab92..2257d09 100644 --- a/config/make.mk +++ b/config/make.mk @@ -1,2 +1,3 @@ dst/dev.toml: config/dev.toml + @mkdir -p $(@D) ln -f $< $@ diff --git a/theseus-server/Cargo.toml b/theseus-server/Cargo.toml index fefce52..2e782f1 100644 --- a/theseus-server/Cargo.toml +++ b/theseus-server/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] +askama = "0.14.0" chrono = "0.4.41" http-body-util = "0.1.3" hyper = { version = "1.6.0", features = ["full"] } @@ -11,3 +12,4 @@ hyper-util = { version = "0.1.11", features = ["full"] } serde = { version = "1.0.219", features = ["derive"] } tokio = { version = "1.44.2", features = ["full"] } toml = "0.8.22" +url = "2.5.4" diff --git a/theseus-server/askama.toml b/theseus-server/askama.toml new file mode 100644 index 0000000..40c5116 --- /dev/null +++ b/theseus-server/askama.toml @@ -0,0 +1,2 @@ +[general] +dirs = ["../dst/"] diff --git a/theseus-server/src/config.rs b/theseus-server/src/config.rs index 33af1a5..4de4f0c 100644 --- a/theseus-server/src/config.rs +++ b/theseus-server/src/config.rs @@ -1,9 +1,8 @@ -use std::net::SocketAddr; use serde::Deserialize; macro_rules! def_config { ($(config $config_id: ident - { $([$namespace: ident $struct_name: ident]$($var_id: ident = $var_type: ty),*)*$(,)? })* + { $([$namespace: ident $struct_name: ident]$($var_id: ident = $var_type: ty),*$(,)?)* })* ) => { $( #[derive(Deserialize)] @@ -24,7 +23,17 @@ def_config! { config Config { [server Server] - bind_to = SocketAddr, // where to bind the server tcp socket in format: ":" + bind_to = std::net::SocketAddr, // recommended format: ":" + max_question_body_size = u64 // in bytes + + [performance Performance] + memory_limit = usize, + + [maintenance Maintenance] + interval = u64, // in seconds + + [push Push] + endpoint = std::net::SocketAddr, // recommended format: ":" } diff --git a/theseus-server/src/logger.rs b/theseus-server/src/logger.rs index bf0c6a2..9e988d9 100644 --- a/theseus-server/src/logger.rs +++ b/theseus-server/src/logger.rs @@ -17,4 +17,9 @@ macro_rules! log { let now = chrono::Local::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, false); println!(concat!("[{}] ERROR ", $text), now, $($($arg),*)?); }; + + (fatal $text: literal$(, $($arg: expr),*$(,)?)?) => { + let now = chrono::Local::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, false); + println!(concat!("[{}] !!FATAL ERROR!! ", $text), now, $($($arg),*)?); + }; } diff --git a/theseus-server/src/main.rs b/theseus-server/src/main.rs index c943fce..44ff6e0 100644 --- a/theseus-server/src/main.rs +++ b/theseus-server/src/main.rs @@ -1,62 +1,259 @@ -use std::sync::{atomic::AtomicU32, Arc}; -use http_body_util::{combinators::BoxBody, BodyExt, Full}; +use std::{collections::HashSet, sync::{atomic::{AtomicUsize, Ordering}, Arc}}; +use askama::Template; +use http_body_util::{combinators::BoxBody, BodyExt, Collected, Full}; use hyper::{ - body::{Bytes, Incoming}, - server::conn::http1, service::service_fn, Error, Method, Request, Response + body::{Body, Bytes, Incoming}, + header::HeaderValue, server::conn::http1, service::service_fn, Error, Method, Request, Response }; use hyper_util::rt::TokioIo; -use tokio::net::TcpListener; +use tokio::{net::TcpListener, sync::Mutex}; mod logger; mod args; mod config; +const CTYPE_FORM: HeaderValue = HeaderValue::from_static("application/x-www-form-urlencoded"); + macro_rules! response { - ($status: ident $body: expr) => {{ + (@server) => { "zumepro/ask_stallman" }; + + (file $req: expr, $source: literal, $mime: literal) => { + response!( + $req, OK include_str!(concat!("../../dst/", $source)), + CONTENT_TYPE: $mime, + CACHE_CONTROL: "max-age=180, public", + ) + }; + + (main_page $req: expr, $message_class: literal, $message: literal) => { + response!( $req, OK MainPage { + ntfy_class: $message_class, message: $message, prefill_question: None + }.render()?, CONTENT_TYPE: "text/html") + }; + + (main_page $req: expr, $message_class: literal, $message: literal$(, $prefill: expr)?) => { + response!($req, OK MainPage { + ntfy_class: $message_class, message: $message, prefill_question: Some($prefill) + }.render()?, CONTENT_TYPE: "text/html") + }; + + ($req: expr, $status: ident $body: expr) => {{ + log!(info "{} \"{} {:?}\"", hyper::StatusCode::$status, $req.method(), $req.uri()); let mut res = Response::new(Full::new(Bytes::from($body)).map_err(|n| match n {}).boxed()); + res.headers_mut().append( + hyper::header::SERVER, hyper::header::HeaderValue::from_static(response!(@server)) + ); *res.status_mut() = hyper::StatusCode::$status; res }}; - ($status: ident $body: expr, $($hkey: ident : $hval: literal),*$(,)?) => {{ + ($req: expr, $status: ident $body: expr, $($hkey: ident : $hval: literal),*$(,)?) => {{ + log!(info "{} \"{} {:?}\"", hyper::StatusCode::$status, $req.method(), $req.uri()); let mut res = Response::new(Full::new(Bytes::from($body)).map_err(|n| match n {}).boxed()); *res.status_mut() = hyper::StatusCode::$status; + res.headers_mut().append( + hyper::header::SERVER, hyper::header::HeaderValue::from_static(response!(@server)) + ); $(res.headers_mut() .append(hyper::header::$hkey, hyper::header::HeaderValue::from_static($hval));)* res }}; } +#[derive(askama::Template, Default)] +#[template(path = "index.html")] +struct MainPage<'a> { + ntfy_class: &'a str, + message: &'a str, + prefill_question: Option, +} + +#[derive(Debug)] +enum RouterError { + Templating(askama::Error), + NotImplemented, + IO(Error), +} + +impl std::fmt::Display for RouterError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Templating(e) => write!(f, "templating: {}", e), + Self::IO(e) => write!(f, "io: {}", e), + Self::NotImplemented => write!(f, "not implemented"), + } + } +} + +impl std::error::Error for RouterError { + fn cause(&self) -> Option<&dyn std::error::Error> { + match self { + Self::Templating(e) => Some(e), + Self::IO(e) => Some(e), + _ => None, + } + } + + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Templating(e) => Some(e), + Self::IO(e) => Some(e), + _ => None, + } + } + + fn description(&self) -> &str { + match self { + Self::Templating(_) => "could not construct template", + Self::IO(_) => "i/o error", + Self::NotImplemented => "reached a code block not yet implemented", + } + } +} + +impl From for RouterError { + fn from(value: Error) -> Self { + Self::IO(value) + } +} + +impl From for RouterError { + fn from(value: askama::Error) -> Self { + Self::Templating(value) + } +} + async fn router( req: Request, - _: Arc, -) -> Result>, Error> { + state: Arc, +) -> Result>, RouterError> { match (req.method(), req.uri().path()) { - (&Method::GET, "/") => Ok(response!( - OK include_str!("../../dst/index.html"), - CONTENT_TYPE: "text/html", - )), - _ => Ok(response!( + // pages + (&Method::GET, "/") => Ok(response!(main_page req, "", "")), + (&Method::POST, "/") => new_question(req, state).await, + + // assets + (&Method::GET, "/script.js") => Ok(response!(file req, "script.js", "text/javascript")), + (&Method::GET, "/style.css") => Ok(response!(file req, "style.css", "text/css")), + + _ => Ok(response!(req, NOT_FOUND include_str!("../../dst/not_found.html"), CONTENT_TYPE: "text/html", )), } } -fn load_config(path: &str) -> Result { - let Ok(file_contents) = std::fs::read_to_string(path) else { - return Err("could not read the config file"); +fn parse_form<'a>(bytes: &'a [u8]) -> Result, ()> { + let parsed = url::form_urlencoded::parse(bytes); + for field in parsed { + if field.0 == "question" { + return Ok(field.1); + } + } + Err(()) +} + +async fn new_question( + mut req: Request, + state: Arc, +) -> Result>, RouterError> { + // check size + let body_size = req.body().size_hint().upper().unwrap_or(u64::MAX); + if body_size > state.config.server.max_question_body_size { + return Ok(response!(main_page req, "error", "Question was too long to add.")); + } + + // check headers + if req.headers().get(hyper::header::CONTENT_TYPE) != Some(&CTYPE_FORM) { + return Ok(response!(main_page req, + "error", "Your browser sent a POST request without form data. Please try again." + )); + } + + // get question + let body = (&mut req).collect().await?.to_bytes(); + let Ok(question) = parse_form(&body) else { + return Ok(response!(main_page req, + "error", "The question your browser sent was in invalid format. Please try again." + )); }; - toml::from_str(&file_contents).map_err(|_| "invalid config file structure or fields") + let question = question.to_string(); + + // insert question + match state.questions.lock().await.add_new(question) { + Ok(()) => {}, + Err(()) => return Ok(response!(main_page req, + "error", "We got too many questions in total. So we are not accepting new ones \ + anymore. We are so sorry. :(") + ) + } + + Ok(response!(main_page req, "info", "Your question was successfully added.")) +} + +async fn maintenance(state: Arc) { + let interval = std::time::Duration::from_secs(state.config.maintenance.interval); + log!(debug "started maintenance routine with {:?} interval", interval); + loop { + log!(debug "running maintenance"); + tokio::time::sleep(interval).await; + } +} + +fn load_config(path: &str) -> Result { + let Ok(file_contents) = std::fs::read_to_string(path) else { + return Err("could not read the config file".to_string()); + }; + toml::from_str(&file_contents) + .map_err(|e| format!("invalid config file structure or fields: {:?}", e)) +} + +#[derive(Default)] +struct Questions { + total_size: usize, + max_size: usize, + to_push: HashSet, + pushed: HashSet, +} + +impl Questions { + fn with_capacity(max_size: usize) -> Self { + Self { + max_size, + ..Default::default() + } + } + + fn add_new(&mut self, question: String) -> Result<(), ()> { + if self.pushed.contains(&question) || self.to_push.contains(&question) { return Ok(()); } + if self.total_size + question.len() > self.max_size { + return Err(()); + } + self.total_size += question.len(); + self.to_push.insert(question); + Ok(()) + } + + fn consume_to_push(&mut self) -> HashSet { + for question in self.to_push.iter() { + self.pushed.insert(question.clone()); + } + std::mem::take(&mut self.to_push) + } } struct SharedState { - counter: AtomicU32, + config: config::Config, + questions: Mutex, } -impl Default for SharedState { - fn default() -> Self { - Self { counter: 0.into() } +impl SharedState { + fn new(config: config::Config) -> Self { + let memory_limit = config.performance.memory_limit; + Self { + config, + questions: Mutex::new(Questions::with_capacity(memory_limit)), + } } } @@ -73,34 +270,39 @@ async fn main() { let config = match load_config(args.config_path()) { Ok(v) => v, Err(e) => { - eprintln!("{}", e); + log!(fatal "{}", e); return; } }; // shared state - let state = Arc::new(SharedState::default()); + let state = Arc::new(SharedState::new(config)); - // server - let Ok(listener) = TcpListener::bind(config.server.bind_to).await else { - eprintln!("unable to bind to: {:?}", config.server.bind_to); + // server initialization + let Ok(listener) = TcpListener::bind(state.config.server.bind_to).await else { + log!(fatal "unable to bind to {:?}", state.config.server.bind_to); return; }; + // server runtime + let state_maintenance = state.clone(); + tokio::task::spawn(async move { maintenance(state_maintenance).await; }); + log!(info "listening on {:?}", state.config.server.bind_to); loop { let Ok((stream, addr)) = listener.accept().await else { - eprintln!("unable to accept new connections"); + log!(fatal "unable to accept new connections"); return; }; - let io = TokioIo::new(stream); log!(debug "new connection from {:?}", addr); + let io = TokioIo::new(stream); let state_clone = state.clone(); tokio::task::spawn(async move { if let Err(_) = http1::Builder::new().serve_connection(io, service_fn(move |req| { router(req, state_clone.clone()) })).await { - println!("closed connection"); + log!(debug "transport error to {:?}", addr); } + log!(debug "closing connection to {:?}", addr); }); } }