{"messages":[{"content":"You are a constitutional council ranking individual git commits for ownership allocation.\n\nCompare these two commits. Decide which contributed more lasting value to the project.\n\nJudge substance, not spectacle:\n- Prefer correct, lasting design and real bugfixes over churn, formatting, renames, or generated noise.\n- Prefer clarity and necessity over sheer line count. A small precise change can beat a large diffuse one.\n- Do not favor a side merely because its patch is longer or noisier.\n- Weight what the change does for the project, not the contributor's name.\n\nReturn ONLY a JSON object: {\"winner\": \"A\" or \"B\", \"ratio\": \"N:M\", \"explanation\": \"...\"}\nThe explanation must cite concrete differences in the patches (1-3 sentences).\n\nSide A — contributor: tommy-mor\nSide A — commit message:\n[bf118bdf] Sanitize Reddit entity body HTML before rendering.\n\nUse ammonia at render time so untrusted selftext_html cannot execute scripts in our origin.\n\nCo-authored-by: Cursor \n\nSide A — unified diff (full patch):\ndiff --git a/Cargo.lock b/Cargo.lock\nindex 3dec7cb72a182dc654a37dca8ba0b49d77504daa..0dd4fce5fb6400ae153cca4e3dbf5a5158e6d8b4 100644\n--- a/Cargo.lock\n+++ b/Cargo.lock\n@@ -11,6 +11,19 @@ dependencies = [\n \"memchr\",\n ]\n \n+[[package]]\n+name = \"ammonia\"\n+version = \"4.1.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6\"\n+dependencies = [\n+ \"cssparser\",\n+ \"html5ever\",\n+ \"maplit\",\n+ \"tendril\",\n+ \"url\",\n+]\n+\n [[package]]\n name = \"anyhow\"\n version = \"1.0.102\"\n@@ -355,6 +368,29 @@ version = \"0.2.4\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5\"\n \n+[[package]]\n+name = \"cssparser\"\n+version = \"0.35.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa\"\n+dependencies = [\n+ \"cssparser-macros\",\n+ \"dtoa-short\",\n+ \"itoa\",\n+ \"phf\",\n+ \"smallvec\",\n+]\n+\n+[[package]]\n+name = \"cssparser-macros\"\n+version = \"0.6.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331\"\n+dependencies = [\n+ \"quote\",\n+ \"syn\",\n+]\n+\n [[package]]\n name = \"deranged\"\n version = \"0.5.8\"\n@@ -381,6 +417,21 @@ version = \"0.15.7\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b\"\n \n+[[package]]\n+name = \"dtoa\"\n+version = \"1.0.11\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590\"\n+\n+[[package]]\n+name = \"dtoa-short\"\n+version = \"0.3.5\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87\"\n+dependencies = [\n+ \"dtoa\",\n+]\n+\n [[package]]\n name = \"durable\"\n version = \"0.2.0\"\n@@ -482,6 +533,16 @@ dependencies = [\n \"percent-encoding\",\n ]\n \n+[[package]]\n+name = \"futf\"\n+version = \"0.1.5\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843\"\n+dependencies = [\n+ \"mac\",\n+ \"new_debug_unreachable\",\n+]\n+\n [[package]]\n name = \"futures-channel\"\n version = \"0.3.32\"\n@@ -614,6 +675,17 @@ version = \"0.5.0\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea\"\n \n+[[package]]\n+name = \"html5ever\"\n+version = \"0.35.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4\"\n+dependencies = [\n+ \"log\",\n+ \"markup5ever\",\n+ \"match_token\",\n+]\n+\n [[package]]\n name = \"http\"\n version = \"1.4.1\"\n@@ -974,6 +1046,15 @@ version = \"0.8.2\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0\"\n \n+[[package]]\n+name = \"lock_api\"\n+version = \"0.4.14\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965\"\n+dependencies = [\n+ \"scopeguard\",\n+]\n+\n [[package]]\n name = \"log\"\n version = \"0.4.30\"\n@@ -990,6 +1071,40 @@ dependencies = [\n \"libc\",\n ]\n \n+[[package]]\n+name = \"mac\"\n+version = \"0.1.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4\"\n+\n+[[package]]\n+name = \"maplit\"\n+version = \"1.0.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d\"\n+\n+[[package]]\n+name = \"markup5ever\"\n+version = \"0.35.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3\"\n+dependencies = [\n+ \"log\",\n+ \"tendril\",\n+ \"web_atoms\",\n+]\n+\n+[[package]]\n+name = \"match_token\"\n+version = \"0.35.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf\"\n+dependencies = [\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"syn\",\n+]\n+\n [[package]]\n name = \"matchers\"\n version = \"0.2.0\"\n@@ -1092,6 +1207,12 @@ dependencies = [\n \"tempfile\",\n ]\n \n+[[package]]\n+name = \"new_debug_unreachable\"\n+version = \"1.0.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086\"\n+\n [[package]]\n name = \"nom\"\n version = \"7.1.3\"\n@@ -1175,6 +1296,29 @@ dependencies = [\n \"vcpkg\",\n ]\n \n+[[package]]\n+name = \"parking_lot\"\n+version = \"0.12.5\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a\"\n+dependencies = [\n+ \"lock_api\",\n+ \"parking_lot_core\",\n+]\n+\n+[[package]]\n+name = \"parking_lot_core\"\n+version = \"0.9.12\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1\"\n+dependencies = [\n+ \"cfg-if\",\n+ \"libc\",\n+ \"redox_syscall\",\n+ \"smallvec\",\n+ \"windows-link\",\n+]\n+\n [[package]]\n name = \"peeking_take_while\"\n version = \"0.1.2\"\n@@ -1187,6 +1331,58 @@ version = \"2.3.2\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220\"\n \n+[[package]]\n+name = \"phf\"\n+version = \"0.11.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078\"\n+dependencies = [\n+ \"phf_macros\",\n+ \"phf_shared\",\n+]\n+\n+[[package]]\n+name = \"phf_codegen\"\n+version = \"0.11.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a\"\n+dependencies = [\n+ \"phf_generator\",\n+ \"phf_shared\",\n+]\n+\n+[[package]]\n+name = \"phf_generator\"\n+version = \"0.11.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d\"\n+dependencies = [\n+ \"phf_shared\",\n+ \"rand 0.8.6\",\n+]\n+\n+[[package]]\n+name = \"phf_macros\"\n+version = \"0.11.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216\"\n+dependencies = [\n+ \"phf_generator\",\n+ \"phf_shared\",\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"syn\",\n+]\n+\n+[[package]]\n+name = \"phf_shared\"\n+version = \"0.11.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5\"\n+dependencies = [\n+ \"siphasher\",\n+]\n+\n [[package]]\n name = \"pin-project-lite\"\n version = \"0.2.17\"\n@@ -1223,6 +1419,12 @@ dependencies = [\n \"zerocopy\",\n ]\n \n+[[package]]\n+name = \"precomputed-hash\"\n+version = \"0.1.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c\"\n+\n [[package]]\n name = \"prettyplease\"\n version = \"0.2.37\"\n@@ -1379,6 +1581,15 @@ dependencies = [\n \"rand_core 0.9.5\",\n ]\n \n+[[package]]\n+name = \"redox_syscall\"\n+version = \"0.5.18\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d\"\n+dependencies = [\n+ \"bitflags 2.11.1\",\n+]\n+\n [[package]]\n name = \"regex\"\n version = \"1.12.3\"\n@@ -1563,6 +1774,12 @@ dependencies = [\n \"windows-sys 0.61.2\",\n ]\n \n+[[package]]\n+name = \"scopeguard\"\n+version = \"1.2.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49\"\n+\n [[package]]\n name = \"security-framework\"\n version = \"3.7.0\"\n@@ -1683,6 +1900,12 @@ dependencies = [\n \"libc\",\n ]\n \n+[[package]]\n+name = \"siphasher\"\n+version = \"1.0.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649\"\n+\n [[package]]\n name = \"slab\"\n version = \"0.4.12\"\n@@ -1709,6 +1932,7 @@ dependencies = [\n name = \"sorter2-server\"\n version = \"0.0.1\"\n dependencies = [\n+ \"ammonia\",\n \"async-stream\",\n \"axum\",\n \"axum-extra\",\n@@ -1742,6 +1966,31 @@ version = \"1.2.1\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596\"\n \n+[[package]]\n+name = \"string_cache\"\n+version = \"0.8.9\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f\"\n+dependencies = [\n+ \"new_debug_unreachable\",\n+ \"parking_lot\",\n+ \"phf_shared\",\n+ \"precomputed-hash\",\n+ \"serde\",\n+]\n+\n+[[package]]\n+name = \"string_cache_codegen\"\n+version = \"0.5.4\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0\"\n+dependencies = [\n+ \"phf_generator\",\n+ \"phf_shared\",\n+ \"proc-macro2\",\n+ \"quote\",\n+]\n+\n [[package]]\n name = \"subtle\"\n version = \"2.6.1\"\n@@ -1813,6 +2062,17 @@ dependencies = [\n \"windows-sys 0.61.2\",\n ]\n \n+[[package]]\n+name = \"tendril\"\n+version = \"0.4.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0\"\n+dependencies = [\n+ \"futf\",\n+ \"mac\",\n+ \"utf-8\",\n+]\n+\n [[package]]\n name = \"thiserror\"\n version = \"1.0.69\"\n@@ -2116,6 +2376,12 @@ version = \"2.1.3\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da\"\n \n+[[package]]\n+name = \"utf-8\"\n+version = \"0.7.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9\"\n+\n [[package]]\n name = \"utf8_iter\"\n version = \"1.0.4\"\n@@ -2281,6 +2547,18 @@ dependencies = [\n \"wasm-bindgen\",\n ]\n \n+[[package]]\n+name = \"web_atoms\"\n+version = \"0.1.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414\"\n+dependencies = [\n+ \"phf\",\n+ \"phf_codegen\",\n+ \"string_cache\",\n+ \"string_cache_codegen\",\n+]\n+\n [[package]]\n name = \"windows-link\"\n version = \"0.2.1\"\ndiff --git a/server/Cargo.toml b/server/Cargo.toml\nindex 47659ff82fbfb50972eb2b87575e80f66e572ba4..27f552c20b97ef28cdde4cb6b1a4980375135111 100644\n--- a/server/Cargo.toml\n+++ b/server/Cargo.toml\n@@ -13,6 +13,7 @@ serde = { version = \"1\", features = [\"derive\"] }\n serde_json = \"1\"\n thiserror = \"1\"\n maud = { version = \"0.26\", features = [\"axum\"] }\n+ammonia = \"4.1\"\n tower = \"0.5\"\n tower-http = { version = \"0.5\", features = [\"trace\"] }\n tracing = \"0.1\"\ndiff --git a/server/src/fetch/html.rs b/server/src/fetch/html.rs\nindex dadf050515f0473943dad97df5d318032c8cb385..5b160c6b8bd216dfaf80149854aec0566cd00460 100644\n--- a/server/src/fetch/html.rs\n+++ b/server/src/fetch/html.rs\n@@ -4,6 +4,7 @@ use maud::{html, Markup};\n \n use crate::{\n form_template::template_json_compact,\n+ html::sanitize::entity_body_html,\n path_types::ItemId,\n reddit::{is_children_fetchable, is_fetchable},\n reducer::NodeState,\n@@ -27,7 +28,7 @@ pub fn entity_panel(node: &NodeState) -> Markup {\n p class=\"muted small\" { \"by \" (author) }\n }\n @if let Some(body) = &data.body_html {\n- div class=\"entity-body\" { (maud::PreEscaped(body)) }\n+ div class=\"entity-body\" { (maud::PreEscaped(entity_body_html(body))) }\n }\n }\n }\ndiff --git a/server/src/html/mod.rs b/server/src/html/mod.rs\nindex e180a0ca542a33e2300c0a4809e6b9cfee07ecfe..a58cbbee3490a08a625cb06df06848c59a615d65 100644\n--- a/server/src/html/mod.rs\n+++ b/server/src/html/mod.rs\n@@ -20,6 +20,7 @@ use crate::{\n ui_action::UI_RPC_FIELD,\n };\n \n+pub mod sanitize;\n pub mod vote;\n \n const SORTER_CSS: &str = include_str!(\"../../static/sorter.css\");\ndiff --git a/server/src/html/sanitize.rs b/server/src/html/sanitize.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..6685ed2535281b9fd5d65b042cbc5a11c5d4df37\n--- /dev/null\n+++ b/server/src/html/sanitize.rs\n@@ -0,0 +1,39 @@\n+//! Whitelist sanitization for untrusted HTML fragments (e.g. Reddit `selftext_html`).\n+\n+use std::sync::LazyLock;\n+\n+use ammonia::Builder;\n+\n+static ENTITY_BODY: LazyLock> = LazyLock::new(|| {\n+ let mut b = Builder::default();\n+ b.strip_comments(true);\n+ b.link_rel(Some(\"noopener noreferrer\"));\n+ b\n+});\n+\n+/// Sanitize HTML safe for embedding in our pages via [`maud::PreEscaped`].\n+pub fn entity_body_html(raw: &str) -> String {\n+ ENTITY_BODY.clean(raw).to_string()\n+}\n+\n+#[cfg(test)]\n+mod tests {\n+ use super::entity_body_html;\n+\n+ #[test]\n+ fn keeps_benign_markup() {\n+ assert_eq!(\n+ entity_body_html(\"

release notes

\"),\n+ \"

release notes

\"\n+ );\n+ }\n+\n+ #[test]\n+ fn strips_scripts_and_event_handlers() {\n+ let raw = \"

ok

\";\n+ let clean = entity_body_html(raw);\n+ assert!(!clean.contains(\"ok

\"));\n+ }\n+}\ndiff --git a/server/src/render/reddit.rs b/server/src/render/reddit.rs\nindex c4f98fc760c32ce1b41a91bd41e97908bd198937..7f840aa33b734a31d8cf3341a0581c8bcb9bbcf3 100644\n--- a/server/src/render/reddit.rs\n+++ b/server/src/render/reddit.rs\n@@ -3,6 +3,7 @@\n use maud::{html, Markup};\n \n use crate::{\n+ html::sanitize::entity_body_html,\n path_types::ItemId,\n reducer::{EntityData, GlobalTree, NodeState},\n };\n@@ -57,7 +58,7 @@ fn post_entity_card(data: &EntityData) -> Markup {\n }\n }\n @if let Some(body) = &data.body_html {\n- div class=\"entity-body\" { (maud::PreEscaped(body)) }\n+ div class=\"entity-body\" { (maud::PreEscaped(entity_body_html(body))) }\n }\n }\n }\n\n\nSide B — contributor: tommy-mor\nSide B — commit message:\n[c3cbcaa7] refactor\n\nSide B — unified diff (full patch):\ndiff --git a/server/src/api/auth.rs b/server/src/api/auth.rs\nindex b3631b06153d52f88348fef927e7a024b7b85ad6..cd556eb89e9dd8203eba6c8969ffd144db5d329d 100644\n--- a/server/src/api/auth.rs\n+++ b/server/src/api/auth.rs\n@@ -71,6 +71,24 @@ pub fn optional_principal(headers: &HeaderMap, jar: &CookieJar, reduced: &Reduce\n verify_token(reduced, c.value()).ok()\n }\n \n+/// Browser session: principal + bearer token string (same shape as CLI session cookie).\n+#[derive(Debug, Clone)]\n+pub struct WebSession {\n+ pub username: String,\n+ pub bearer: String,\n+}\n+\n+/// Resolve username and bearer together for `POST /ui` dispatch (one read of headers + jar).\n+pub fn resolve_web_session(headers: &HeaderMap, jar: &CookieJar, reduced: &ReducerState) -> Option {\n+ let username = optional_principal(headers, jar, reduced)?;\n+ let bearer = headers\n+ .get(header::AUTHORIZATION)\n+ .and_then(|v| v.to_str().ok())\n+ .and_then(|s| s.strip_prefix(\"Bearer \").map(|t| t.trim().to_string()))\n+ .or_else(|| jar.get(SLUG_SESSION_COOKIE).map(|c| c.value().to_string()))?;\n+ Some(WebSession { username, bearer })\n+}\n+\n fn redirect_with_session_cookie(public_url: &str, path_and_query: &str, bearer: &str, jar: &CookieJar) -> Response {\n let mut res = Response::builder()\n .status(StatusCode::TEMPORARY_REDIRECT)\ndiff --git a/server/src/api/mod.rs b/server/src/api/mod.rs\nindex a986f706ea4b261cbaf004c02b4cf84184b41371..4e223a7460997c464706dca850281447bd754ed5 100644\n--- a/server/src/api/mod.rs\n+++ b/server/src/api/mod.rs\n@@ -4,7 +4,6 @@ mod rpc;\n mod stream;\n mod validate;\n mod ui_html;\n-mod web_post;\n \n pub use auth::{\n get_join_invite,\n@@ -19,7 +18,9 @@ pub use auth::{\n get_web_login,\n get_logout,\n optional_principal,\n+ resolve_web_session,\n session_cookie_header_value,\n+ WebSession,\n SLUG_SESSION_COOKIE,\n };\n \n@@ -35,7 +36,6 @@ pub use stream::{get_html_stream, get_stream};\n pub use validate::{normalize_room_and_thread, validate_ingest_document, ValidatedIngest};\n \n pub use ui_html::post_ui_html;\n-pub use web_post::{check_web_ingest, post_web_ingest, post_web_redact};\n \n #[cfg(test)]\n mod tests {\ndiff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs\nindex 2b40a72059981d558768f73d189b991f3448c257..497f8fdd222a8ec7c3d76b0695e0352e973ca3ba 100644\n--- a/server/src/api/ui_html.rs\n+++ b/server/src/api/ui_html.rs\n@@ -1,25 +1,29 @@\n-//! Single `POST /ui` entry for browser [`crate::html::ui_action::HtmlUiAction`] (JSON in `__rpc__` + holes).\n+//! Single `POST /ui` entry: parse `__rpc__` → [`HtmlUiAction`], resolve [`WebSession`] once, dispatch.\n \n use axum::{\n- body::Body,\n+ body,\n extract::State,\n- http::{header, HeaderMap, StatusCode},\n+ http::{header, HeaderMap, HeaderValue, StatusCode},\n response::{IntoResponse, Response},\n Form,\n };\n use axum_extra::extract::cookie::CookieJar;\n+use slug_types::{RpcBatch, RpcBatchResponse, RpcCommand, RpcResult};\n use std::collections::HashMap;\n \n use crate::{\n api::{\n- auth::optional_principal,\n- web_post::{run_check_web_ingest, run_post_web_ingest, run_post_web_redact, WebPostForm, WebRedactForm},\n+ auth::{resolve_web_session, WebSession},\n+ handle_rpc_batch,\n+ rpc::{rpc_post_redact, rpc_post_with_bearer},\n },\n+ canonical_path::canonicalize_tag,\n html::{\n fragment_public_new_thread_form, fragment_room_new_thread_form, login_to_post_hint_markup,\n- parse_html_ui_from_form, user_can_post_room, user_can_view_room, HtmlUiAction, JsBuilder,\n- ThreadNav,\n+ parse_html_ui_from_form, thread_feed_html, thread_feed_html_for_room, thread_feed_region_markup,\n+ user_can_post_room, user_can_view_room, HtmlUiAction, JsBuilder, ThreadNav,\n },\n+ reducer::{scope_from_room_wire, ScopeId},\n state::AppState,\n };\n \n@@ -34,6 +38,19 @@ pub async fn post_ui_html(\n Err(e) => return ui_js_warn(&e.to_string()).into_response(),\n };\n \n+ let reduced = state.reduced.read().await;\n+ let session = resolve_web_session(&headers, &jar, &reduced);\n+ drop(reduced);\n+\n+ dispatch_ui_action(&state, session.as_ref(), action).await\n+}\n+\n+/// All UI command logic: HTTP extractors stop above; this only sees [`AppState`], session, and [`HtmlUiAction`].\n+async fn dispatch_ui_action(\n+ state: &AppState,\n+ session: Option<&WebSession>,\n+ action: HtmlUiAction,\n+) -> Response {\n match action {\n HtmlUiAction::PostIngest {\n room,\n@@ -42,47 +59,87 @@ pub async fn post_ui_html(\n error_target,\n form_id,\n } => {\n- run_post_web_ingest(\n- &state,\n- &headers,\n- &jar,\n- WebPostForm {\n- room,\n- thread_tag,\n- text,\n- error_target,\n- form_id,\n- },\n- )\n- .await\n+ let Some(session) = session else {\n+ return js_redirect(\"/login\").into_response();\n+ };\n+ let room = room.trim().to_string();\n+ let thread_tag = thread_tag.trim().to_string();\n+ if text.trim().is_empty() {\n+ return form_js_error(\n+ error_target.as_ref(),\n+ \"empty post\",\n+ \"Write something in the text area (DSL / prose).\",\n+ )\n+ .into_response();\n+ }\n+ match rpc_post_with_bearer(state, &session.bearer, room.clone(), thread_tag.clone(), text).await {\n+ Ok(RpcResult::PostOk { .. }) => {\n+ post_success_response(\n+ state,\n+ &room,\n+ &thread_tag,\n+ error_target.as_ref(),\n+ form_id.as_ref(),\n+ Some(session.username.as_str()),\n+ )\n+ .await\n+ .into_response()\n+ }\n+ Ok(_) => form_js_error(\n+ error_target.as_ref(),\n+ \"unexpected response\",\n+ \"Post did not return PostOk.\",\n+ )\n+ .into_response(),\n+ Err((msg, hint)) => form_js_error(error_target.as_ref(), &msg, hint.as_deref().unwrap_or(\"\")).into_response(),\n+ }\n }\n HtmlUiAction::CheckIngest {\n room,\n thread_tag,\n text,\n error_target,\n- form_id,\n+ form_id: _,\n } => {\n- run_check_web_ingest(\n- &state,\n- &headers,\n- &jar,\n- WebPostForm {\n- room,\n- thread_tag,\n- text,\n- error_target,\n- form_id,\n- },\n- )\n- .await\n+ let Some(session) = session else {\n+ return js_redirect(\"/login\").into_response();\n+ };\n+ let room = room.trim().to_string();\n+ let thread_tag = canonicalize_tag(&thread_tag);\n+ if thread_tag.is_empty() {\n+ return form_js_error(\n+ error_target.as_ref(),\n+ \"missing thread tag\",\n+ \"Set a thread tag before posting.\",\n+ )\n+ .into_response();\n+ }\n+ if text.trim().is_empty() {\n+ return js_clear_errors(&form_error_target(error_target.as_ref())).into_response();\n+ }\n+ match rpc_check_with_bearer(state, &session.bearer, room, text.clone()).await {\n+ Ok(RpcResult::CheckOk { .. }) => js_clear_errors(&form_error_target(error_target.as_ref())).into_response(),\n+ Ok(_) => form_js_error(error_target.as_ref(), \"unexpected response\", \"Check did not return CheckOk.\").into_response(),\n+ Err((msg, hint)) => form_js_error(error_target.as_ref(), &msg, hint.as_deref().unwrap_or(\"\")).into_response(),\n+ }\n }\n HtmlUiAction::RedactPost { post_id } => {\n- run_post_web_redact(&state, &headers, &jar, WebRedactForm { post_id }).await\n+ let Some(session) = session else {\n+ return js_redirect(\"/login\").into_response();\n+ };\n+ let h = headers_from_bearer(&session.bearer);\n+ match rpc_post_redact(state, &h, post_id).await {\n+ Ok(RpcResult::RedactPostOk {}) => redact_success_response(state).await.into_response(),\n+ Ok(_) => (StatusCode::BAD_REQUEST, \"unexpected response\").into_response(),\n+ Err((msg, hint)) => {\n+ let detail = hint.as_deref().unwrap_or(\"\");\n+ js_error(\"#errors\", &msg, detail).into_response()\n+ }\n+ }\n }\n HtmlUiAction::ExpandPublicNewThreadForm => {\n let reduced = state.reduced.read().await;\n- let user = optional_principal(&headers, &jar, &reduced);\n+ let user = session.map(|s| s.username.as_str());\n drop(reduced);\n let markup = if user.is_some() {\n fragment_public_new_thread_form(true)\n@@ -99,18 +156,18 @@ pub async fn post_ui_html(\n return ui_js_warn(\"missing room\").into_response();\n }\n let reduced = state.reduced.read().await;\n- let user = optional_principal(&headers, &jar, &reduced);\n+ let user = session.map(|s| s.username.as_str());\n if !reduced.rooms.contains(&room_wire) {\n drop(reduced);\n return ui_js_warn(\"room not found\").into_response();\n }\n- if !user_can_view_room(&reduced, &room_wire, user.as_deref()) {\n+ if !user_can_view_room(&reduced, &room_wire, user) {\n drop(reduced);\n return ui_js_warn(\"forbidden\").into_response();\n }\n- let can_post = user\n+ let can_post = session\n .as_ref()\n- .map(|u| user_can_post_room(&reduced, &room_wire, u))\n+ .map(|s| user_can_post_room(&reduced, &room_wire, &s.username))\n .unwrap_or(false);\n drop(reduced);\n let Some(nav) = ThreadNav::from_room_id(&room_wire) else {\n@@ -128,12 +185,195 @@ pub async fn post_ui_html(\n }\n }\n \n+fn headers_from_bearer(bearer: &str) -> HeaderMap {\n+ let mut headers = HeaderMap::new();\n+ if let Ok(hv) = HeaderValue::from_str(&format!(\"Bearer {bearer}\")) {\n+ headers.insert(header::AUTHORIZATION, hv);\n+ }\n+ headers\n+}\n+\n+fn post_redirect_location(room: &str, thread_tag: &str) -> String {\n+ let tag = canonicalize_tag(thread_tag);\n+ if room.trim() == \"public\" {\n+ format!(\"/t/{tag}\")\n+ } else {\n+ let room = room.trim();\n+ let Some((a, b)) = room.split_once('/') else {\n+ return \"/\".to_string();\n+ };\n+ format!(\"/r/{a}/{b}/t/{tag}\")\n+ }\n+}\n+\n+fn js_quote(s: &str) -> String {\n+ serde_json::to_string(s).expect(\"js string escaping must succeed\")\n+}\n+\n+fn js_redirect(to: &str) -> Response {\n+ let js = format!(\"window.location = {};\", js_quote(to));\n+ Response::builder()\n+ .status(StatusCode::OK)\n+ .header(header::CONTENT_TYPE, \"text/javascript; charset=utf-8\")\n+ .body(axum::body::Body::from(js))\n+ .unwrap()\n+}\n+\n+fn js_error(error_target: &str, title: &str, detail: &str) -> Response {\n+ let markup = maud::html! {\n+ div id=(error_target.trim_start_matches('#')) {\n+ p class=\"auth-error\" { (title) }\n+ @if !detail.is_empty() {\n+ pre class=\"muted\" { (detail) }\n+ }\n+ }\n+ };\n+ JsBuilder::new()\n+ .morph_selector(error_target, markup)\n+ .into_response()\n+}\n+\n+fn form_error_target(error_target: Option<&String>) -> String {\n+ error_target\n+ .map(|s| s.as_str())\n+ .filter(|s| !s.trim().is_empty())\n+ .map(|s| {\n+ if s.starts_with('#') {\n+ s.to_string()\n+ } else {\n+ format!(\"#{s}\")\n+ }\n+ })\n+ .unwrap_or_else(|| \"#errors\".to_string())\n+}\n+\n+fn form_js_error(error_target: Option<&String>, title: &str, detail: &str) -> Response {\n+ js_error(&form_error_target(error_target), title, detail)\n+}\n+\n+fn js_clear_errors(error_target: &str) -> Response {\n+ let markup = maud::html! {\n+ div id=(error_target.trim_start_matches('#')) {}\n+ };\n+ JsBuilder::new()\n+ .morph_selector(error_target, markup)\n+ .into_response()\n+}\n+\n+fn empty_error_markup(error_target: &str) -> maud::Markup {\n+ maud::html! {\n+ div id=(error_target.trim_start_matches('#')) {}\n+ }\n+}\n+\n+async fn rpc_check_with_bearer(\n+ state: &AppState,\n+ bearer_token: &str,\n+ room: String,\n+ text: String,\n+) -> Result)> {\n+ let mut headers = HeaderMap::new();\n+ let hv = HeaderValue::from_str(&format!(\"Bearer {bearer_token}\"))\n+ .map_err(|_| (\"invalid session token\".into(), None))?;\n+ headers.insert(header::AUTHORIZATION, hv);\n+\n+ let response = handle_rpc_batch(\n+ State(state.clone()),\n+ headers,\n+ axum::Json(RpcBatch(vec![RpcCommand::Check { room, text }])),\n+ )\n+ .await\n+ .into_response();\n+\n+ let status = response.status();\n+ if !status.is_success() {\n+ return Err((format!(\"rpc check http {}\", status), None));\n+ }\n+\n+ let body = body::to_bytes(response.into_body(), usize::MAX)\n+ .await\n+ .map_err(|e| (e.to_string(), None))?;\n+ let parsed: RpcBatchResponse =\n+ serde_json::from_slice(&body).map_err(|e| (e.to_string(), None))?;\n+ let line = parsed\n+ .results\n+ .into_iter()\n+ .next()\n+ .ok_or_else(|| (\"empty rpc check response\".to_string(), None))?;\n+ if line.ok {\n+ line.result\n+ .ok_or_else(|| (\"missing rpc check result\".to_string(), None))\n+ } else {\n+ Err((\n+ line.error.unwrap_or_else(|| \"check failed\".to_string()),\n+ line.hint,\n+ ))\n+ }\n+}\n+\n+async fn post_success_response(\n+ state: &AppState,\n+ room: &str,\n+ thread_tag: &str,\n+ error_target: Option<&String>,\n+ form_id: Option<&String>,\n+ viewer: Option<&str>,\n+) -> Response {\n+ let error_target = form_error_target(error_target);\n+ let room = room.trim().to_string();\n+ let thread_tag = canonicalize_tag(thread_tag);\n+ let thread_location = post_redirect_location(&room, &thread_tag);\n+ let form_id = form_id.map(|s| s.as_str()).unwrap_or(\"\");\n+ let scope = scope_from_room_wire(&room);\n+ let feed_markup = match &scope {\n+ ScopeId::Public => thread_feed_html(state).await,\n+ ScopeId::Room(_) => thread_feed_html_for_room(state, &room).await,\n+ };\n+ let thread_markup = thread_feed_region_markup(\n+ state,\n+ match &scope {\n+ ScopeId::Public => None,\n+ ScopeId::Room(_) => Some(room.as_str()),\n+ },\n+ &thread_tag,\n+ viewer.as_deref(),\n+ )\n+ .await;\n+ let feed_selector = match &scope {\n+ ScopeId::Public => \"#thread-feed\",\n+ ScopeId::Room(_) => \"#room-thread-feed\",\n+ };\n+\n+ let builder = JsBuilder::new()\n+ .morph_selector(&error_target, empty_error_markup(&error_target))\n+ .morph_selector(feed_selector, feed_markup)\n+ .if_current_path_matches(&thread_location, |builder| {\n+ let builder = builder.morph_selector(\"#thread-feed-region\", thread_markup);\n+ let builder = if !form_id.trim().is_empty() {\n+ builder.qs(&format!(\"#{form_id}\")).reset()\n+ } else {\n+ builder\n+ };\n+ builder\n+ })\n+ .if_current_path_not_matches(&thread_location, |builder| builder.redirect(&thread_location));\n+\n+ builder.into_response()\n+}\n+\n+async fn redact_success_response(state: &AppState) -> Response {\n+ let feed_markup = thread_feed_html(state).await;\n+ JsBuilder::new()\n+ .morph_selector(\"#thread-feed\", feed_markup)\n+ .into_response()\n+}\n+\n fn ui_js_warn(msg: &str) -> Response {\n use crate::html::js_string_literal;\n let js = format!(\"console.warn({});\", js_string_literal(msg));\n Response::builder()\n .status(StatusCode::OK)\n .header(header::CONTENT_TYPE, \"text/javascript; charset=utf-8\")\n- .body(Body::from(js))\n+ .body(axum::body::Body::from(js))\n .unwrap()\n }\ndiff --git a/server/src/api/web_post.rs b/server/src/api/web_post.rs\ndeleted file mode 100644\nindex 265025f41ff1548d05b2d2d5d84202245388053f..0000000000000000000000000000000000000000\n--- a/server/src/api/web_post.rs\n+++ /dev/null\n@@ -1,362 +0,0 @@\n-use axum::{\n- body,\n- extract::State,\n- http::{header, HeaderMap, HeaderValue, StatusCode},\n- response::{IntoResponse, Response},\n- Form,\n-};\n-use axum_extra::extract::cookie::CookieJar;\n-use serde::Deserialize;\n-use slug_types::{RpcBatch, RpcBatchResponse, RpcCommand, RpcResult};\n-\n-use crate::{\n- api::{\n- auth::{optional_principal, SLUG_SESSION_COOKIE},\n- handle_rpc_batch,\n- rpc::{rpc_post_redact, rpc_post_with_bearer},\n- },\n- canonical_path::canonicalize_tag,\n- html::{thread_feed_html, thread_feed_html_for_room, thread_feed_region_markup, JsBuilder},\n- reducer::{scope_from_room_wire, ScopeId},\n- state::AppState,\n-};\n-\n-#[derive(Debug, Deserialize)]\n-pub struct WebPostForm {\n- pub room: String,\n- pub thread_tag: String,\n- pub text: String,\n- #[serde(default)]\n- pub error_target: Option,\n- #[serde(default)]\n- pub form_id: Option,\n-}\n-\n-#[derive(Debug, Deserialize)]\n-pub struct WebRedactForm {\n- pub post_id: String,\n-}\n-\n-fn post_redirect_location(room: &str, thread_tag: &str) -> String {\n- let tag = canonicalize_tag(thread_tag);\n- if room.trim() == \"public\" {\n- format!(\"/t/{tag}\")\n- } else {\n- let room = room.trim();\n- let Some((a, b)) = room.split_once('/') else {\n- return \"/\".to_string();\n- };\n- format!(\"/r/{a}/{b}/t/{tag}\")\n- }\n-}\n-\n-fn js_quote(s: &str) -> String {\n- serde_json::to_string(s).expect(\"js string escaping must succeed\")\n-}\n-\n-fn js_redirect(to: &str) -> Response {\n- let js = format!(\"window.location = {};\", js_quote(to));\n- Response::builder()\n- .status(StatusCode::OK)\n- .header(header::CONTENT_TYPE, \"text/javascript; charset=utf-8\")\n- .body(axum::body::Body::from(js))\n- .unwrap()\n-}\n-\n-fn js_error(error_target: &str, title: &str, detail: &str) -> Response {\n- let markup = maud::html! {\n- div id=(error_target.trim_start_matches('#')) {\n- p class=\"auth-error\" { (title) }\n- @if !detail.is_empty() {\n- pre class=\"muted\" { (detail) }\n- }\n- }\n- };\n- JsBuilder::new()\n- .morph_selector(error_target, markup)\n- .into_response()\n-}\n-\n-fn form_error_target(form: &WebPostForm) -> String {\n- form.error_target\n- .as_deref()\n- .filter(|s| !s.trim().is_empty())\n- .map(|s| {\n- if s.starts_with('#') {\n- s.to_string()\n- } else {\n- format!(\"#{s}\")\n- }\n- })\n- .unwrap_or_else(|| \"#errors\".to_string())\n-}\n-\n-fn form_js_error(form: &WebPostForm, title: &str, detail: &str) -> Response {\n- js_error(&form_error_target(form), title, detail)\n-}\n-\n-fn js_clear_errors(error_target: &str) -> Response {\n- let markup = maud::html! {\n- div id=(error_target.trim_start_matches('#')) {}\n- };\n- JsBuilder::new()\n- .morph_selector(error_target, markup)\n- .into_response()\n-}\n-\n-fn empty_error_markup(error_target: &str) -> maud::Markup {\n- maud::html! {\n- div id=(error_target.trim_start_matches('#')) {}\n- }\n-}\n-\n-async fn rpc_check_with_bearer(\n- state: &AppState,\n- bearer_token: &str,\n- room: String,\n- text: String,\n-) -> Result)> {\n- let mut headers = HeaderMap::new();\n- let hv = HeaderValue::from_str(&format!(\"Bearer {bearer_token}\"))\n- .map_err(|_| (\"invalid session token\".into(), None))?;\n- headers.insert(header::AUTHORIZATION, hv);\n-\n- let response = handle_rpc_batch(\n- State(state.clone()),\n- headers,\n- axum::Json(RpcBatch(vec![RpcCommand::Check { room, text }])),\n- )\n- .await\n- .into_response();\n-\n- let status = response.status();\n- if !status.is_success() {\n- return Err((format!(\"rpc check http {}\", status), None));\n- }\n-\n- let body = body::to_bytes(response.into_body(), usize::MAX)\n- .await\n- .map_err(|e| (e.to_string(), None))?;\n- let parsed: RpcBatchResponse =\n- serde_json::from_slice(&body).map_err(|e| (e.to_string(), None))?;\n- let line = parsed\n- .results\n- .into_iter()\n- .next()\n- .ok_or_else(|| (\"empty rpc check response\".to_string(), None))?;\n- if line.ok {\n- line.result\n- .ok_or_else(|| (\"missing rpc check result\".to_string(), None))\n- } else {\n- Err((\n- line.error.unwrap_or_else(|| \"check failed\".to_string()),\n- line.hint,\n- ))\n- }\n-}\n-\n-fn post_success_response<'a>(\n- state: &'a AppState,\n- form: &'a WebPostForm,\n- headers: &'a HeaderMap,\n- jar: &'a CookieJar,\n-) -> impl std::future::Future + 'a {\n- async move {\n- let error_target = form_error_target(form);\n- let room = form.room.trim().to_string();\n- let thread_tag = canonicalize_tag(&form.thread_tag);\n- let thread_location = post_redirect_location(&room, &thread_tag);\n- let form_id = form.form_id.as_deref().unwrap_or_default();\n- let scope = scope_from_room_wire(&room);\n- let viewer = {\n- let reduced = state.reduced.read().await;\n- optional_principal(headers, jar, &reduced)\n- };\n- let feed_markup = match &scope {\n- ScopeId::Public => thread_feed_html(state).await,\n- ScopeId::Room(_) => thread_feed_html_for_room(state, &room).await,\n- };\n- let thread_markup = thread_feed_region_markup(\n- state,\n- match &scope {\n- ScopeId::Public => None,\n- ScopeId::Room(_) => Some(room.as_str()),\n- },\n- &thread_tag,\n- viewer.as_deref(),\n- )\n- .await;\n- let feed_selector = match &scope {\n- ScopeId::Public => \"#thread-feed\",\n- ScopeId::Room(_) => \"#room-thread-feed\",\n- };\n-\n- let builder = JsBuilder::new()\n- .morph_selector(&error_target, empty_error_markup(&error_target))\n- .morph_selector(feed_selector, feed_markup)\n- .if_current_path_matches(&thread_location, |builder| {\n- let builder = builder.morph_selector(\"#thread-feed-region\", thread_markup);\n- let builder = if !form_id.trim().is_empty() {\n- builder.qs(&format!(\"#{form_id}\")).reset()\n- } else {\n- builder\n- };\n- builder\n- })\n- .if_current_path_not_matches(&thread_location, |builder| builder.redirect(&thread_location));\n-\n- builder.into_response()\n- }\n-}\n-\n-async fn redact_success_response(state: &AppState) -> Response {\n- let feed_markup = thread_feed_html(state).await;\n- JsBuilder::new()\n- .morph_selector(\"#thread-feed\", feed_markup)\n- .into_response()\n-}\n-\n-pub async fn post_web_redact(\n- State(state): State,\n- headers: HeaderMap,\n- jar: CookieJar,\n- Form(form): Form,\n-) -> impl IntoResponse {\n- run_post_web_redact(&state, &headers, &jar, form).await\n-}\n-\n-/// Shared with [`crate::api::ui_html::post_ui_html`].\n-pub(crate) async fn run_post_web_redact(\n- state: &AppState,\n- headers: &HeaderMap,\n- jar: &CookieJar,\n- form: WebRedactForm,\n-) -> Response {\n- let reduced = state.reduced.read().await;\n- let Some(_username) = optional_principal(headers, jar, &reduced) else {\n- drop(reduced);\n- return js_redirect(\"/login\").into_response();\n- };\n- drop(reduced);\n-\n- let bearer = headers\n- .get(axum::http::header::AUTHORIZATION)\n- .and_then(|v| v.to_str().ok())\n- .and_then(|s| s.strip_prefix(\"Bearer \").map(|t| t.trim().to_string()))\n- .or_else(|| jar.get(SLUG_SESSION_COOKIE).map(|c| c.value().to_string()));\n-\n- let Some(_bearer) = bearer else {\n- return js_redirect(\"/login\").into_response();\n- };\n-\n- match rpc_post_redact(state, headers, form.post_id).await {\n- Ok(RpcResult::RedactPostOk {}) => redact_success_response(state).await.into_response(),\n- Ok(_) => (StatusCode::BAD_REQUEST, \"unexpected response\").into_response(),\n- Err((msg, hint)) => {\n- let detail = hint.as_deref().unwrap_or(\"\");\n- js_error(\"#errors\", &msg, detail).into_response()\n- }\n- }\n-}\n-\n-pub async fn post_web_ingest(\n- State(state): State,\n- headers: HeaderMap,\n- jar: CookieJar,\n- Form(form): Form,\n-) -> impl IntoResponse {\n- run_post_web_ingest(&state, &headers, &jar, form).await\n-}\n-\n-/// Shared with [`crate::api::ui_html::post_ui_html`] (`POST /ui`).\n-pub(crate) async fn run_post_web_ingest(\n- state: &AppState,\n- headers: &HeaderMap,\n- jar: &CookieJar,\n- form: WebPostForm,\n-) -> Response {\n- let reduced = state.reduced.read().await;\n- let Some(_username) = optional_principal(headers, jar, &reduced) else {\n- drop(reduced);\n- return js_redirect(\"/login\").into_response();\n- };\n-\n- let bearer = headers\n- .get(axum::http::header::AUTHORIZATION)\n- .and_then(|v| v.to_str().ok())\n- .and_then(|s| s.strip_prefix(\"Bearer \").map(|t| t.trim().to_string()))\n- .or_else(|| jar.get(SLUG_SESSION_COOKIE).map(|c| c.value().to_string()));\n-\n- drop(reduced);\n-\n- let Some(bearer) = bearer else {\n- return js_redirect(\"/login\").into_response();\n- };\n-\n- let room = form.room.trim().to_string();\n- let thread_tag = form.thread_tag.trim().to_string();\n- let text = form.text.clone();\n-\n- if text.trim().is_empty() {\n- return form_js_error(&form, \"empty post\", \"Write something in the text area (DSL / prose).\")\n- .into_response();\n- }\n-\n- match rpc_post_with_bearer(state, &bearer, room.clone(), thread_tag.clone(), text).await {\n- Ok(RpcResult::PostOk { .. }) => post_success_response(state, &form, headers, jar)\n- .await\n- .into_response(),\n- Ok(_) => form_js_error(&form, \"unexpected response\", \"Post did not return PostOk.\").into_response(),\n- Err((msg, hint)) => form_js_error(&form, &msg, hint.as_deref().unwrap_or(\"\")).into_response(),\n- }\n-}\n-\n-pub async fn check_web_ingest(\n- State(state): State,\n- headers: HeaderMap,\n- jar: CookieJar,\n- Form(form): Form,\n-) -> impl IntoResponse {\n- run_check_web_ingest(&state, &headers, &jar, form).await\n-}\n-\n-/// Shared with [`crate::api::ui_html::post_ui_html`] (`POST /ui`).\n-pub(crate) async fn run_check_web_ingest(\n- state: &AppState,\n- headers: &HeaderMap,\n- jar: &CookieJar,\n- form: WebPostForm,\n-) -> Response {\n- let reduced = state.reduced.read().await;\n- let Some(_username) = optional_principal(headers, jar, &reduced) else {\n- drop(reduced);\n- return js_redirect(\"/login\").into_response();\n- };\n-\n- let bearer = headers\n- .get(axum::http::header::AUTHORIZATION)\n- .and_then(|v| v.to_str().ok())\n- .and_then(|s| s.strip_prefix(\"Bearer \").map(|t| t.trim().to_string()))\n- .or_else(|| jar.get(SLUG_SESSION_COOKIE).map(|c| c.value().to_string()));\n- drop(reduced);\n-\n- let Some(bearer) = bearer else {\n- return js_redirect(\"/login\").into_response();\n- };\n-\n- let room = form.room.trim().to_string();\n- let thread_tag = canonicalize_tag(&form.thread_tag);\n- if thread_tag.is_empty() {\n- return form_js_error(&form, \"missing thread tag\", \"Set a thread tag before posting.\").into_response();\n- }\n-\n- if form.text.trim().is_empty() {\n- return js_clear_errors(&form_error_target(&form)).into_response();\n- }\n-\n- match rpc_check_with_bearer(state, &bearer, room, form.text.clone()).await {\n- Ok(RpcResult::CheckOk { .. }) => js_clear_errors(&form_error_target(&form)).into_response(),\n- Ok(_) => form_js_error(&form, \"unexpected response\", \"Check did not return CheckOk.\").into_response(),\n- Err((msg, hint)) => form_js_error(&form, &msg, hint.as_deref().unwrap_or(\"\")).into_response(),\n- }\n-}\ndiff --git a/server/src/html/forum.rs b/server/src/html/forum.rs\nindex b1c037f3dd93c7bb96d6624cab6019228432c501..a173e7aea7a81c7cfab5f2f6a5549e0db560fb2b 100644\n--- a/server/src/html/forum.rs\n+++ b/server/src/html/forum.rs\n@@ -17,6 +17,7 @@ use crate::{\n state::AppState,\n timeago,\n };\n+use serde_json::json;\n \n use super::ui_action::{HtmlUiAction, UI_RPC_FIELD};\n \n@@ -182,8 +183,8 @@ fn post_header_row(\n div class=\"ingest-header-row\" {\n (meta)\n @if show_delete {\n- form class=\"post-delete-form\" method=\"POST\" action=\"/post/redact\" {\n- input type=\"hidden\" name=\"post_id\" value=(ing.id);\n+ form class=\"post-delete-form\" method=\"POST\" action=\"/ui\" {\n+ input type=\"hidden\" name=(UI_RPC_FIELD) value=(template_json_compact(&HtmlUiAction::RedactPost { post_id: ing.id.clone() }).unwrap());\n button type=\"submit\" class=\"post-delete-btn\" { \"delete\" }\n }\n }\n@@ -427,13 +428,28 @@ fn compose_form(nav: &ThreadNav, thread_tag: &str, show: bool) -> Markup {\n if !show {\n return html! {};\n }\n+ let rpc_post = template_json_compact(&json!({\n+ \"action\": \"post_ingest\",\n+ \"room\": nav.room_wire,\n+ \"thread_tag\": thread_tag,\n+ \"text\": {\"$form\": \"text\"},\n+ \"error_target\": \"thread-compose-errors\",\n+ \"form_id\": \"thread-compose-form\",\n+ }))\n+ .unwrap();\n+ let rpc_check = template_json_compact(&json!({\n+ \"action\": \"check_ingest\",\n+ \"room\": nav.room_wire,\n+ \"thread_tag\": thread_tag,\n+ \"text\": {\"$form\": \"text\"},\n+ \"error_target\": \"thread-compose-errors\",\n+ \"form_id\": \"thread-compose-form\",\n+ }))\n+ .unwrap();\n html! {\n section class=\"compose\" id=\"thread-compose\" {\n- form id=\"thread-compose-form\" method=\"POST\" action=\"/post\" data-check-action=\"/post/check\" {\n- input type=\"hidden\" name=\"room\" value=(nav.room_wire.clone());\n- input type=\"hidden\" name=\"thread_tag\" value=(thread_tag);\n- input type=\"hidden\" name=\"error_target\" value=\"thread-compose-errors\";\n- input type=\"hidden\" name=\"form_id\" value=\"thread-compose-form\";\n+ form id=\"thread-compose-form\" method=\"POST\" action=\"/ui\" data-check-action=\"/ui\" data-check-rpc=(rpc_check) {\n+ input type=\"hidden\" name=(UI_RPC_FIELD) value=(rpc_post);\n textarea name=\"text\" rows=\"5\" cols=\"80\" placeholder=\"prose or ~/items and votes…\" {}\n p {\n button type=\"submit\" { \"post\" }\n@@ -463,10 +479,22 @@ fn new_thread_form_public(show: bool) -> Markup {\n h3 { \"new public thread\" }\n p class=\"muted\" { \"Set thread tag and body. Example: start with a title line or use the CLI-shaped DSL.\" }\n div id=\"public-new-thread-errors\" {}\n- form id=\"public-new-thread-form\" method=\"POST\" action=\"/post\" data-check-action=\"/post/check\" {\n- input type=\"hidden\" name=\"room\" value=\"public\";\n- input type=\"hidden\" name=\"error_target\" value=\"public-new-thread-errors\";\n- input type=\"hidden\" name=\"form_id\" value=\"public-new-thread-form\";\n+ form id=\"public-new-thread-form\" method=\"POST\" action=\"/ui\" data-check-action=\"/ui\" data-check-rpc=(template_json_compact(&json!({\n+ \"action\": \"check_ingest\",\n+ \"room\": \"public\",\n+ \"thread_tag\": {\"$form\": \"thread_tag\"},\n+ \"text\": {\"$form\": \"text\"},\n+ \"error_target\": \"public-new-thread-errors\",\n+ \"form_id\": \"public-new-thread-form\",\n+ })).unwrap()) {\n+ input type=\"hidden\" name=(UI_RPC_FIELD) value=(template_json_compact(&json!({\n+ \"action\": \"post_ingest\",\n+ \"room\": \"public\",\n+ \"thread_tag\": {\"$form\": \"thread_tag\"},\n+ \"text\": {\"$form\": \"text\"},\n+ \"error_target\": \"public-new-thread-errors\",\n+ \"form_id\": \"public-new-thread-form\",\n+ })).unwrap());\n label for=\"new-thread-tag\" { \"thread tag\" }\n input type=\"text\" id=\"new-thread-tag\" name=\"thread_tag\" pattern=\"[a-z0-9_\\\\-]{1,64}\" placeholder=\"my-topic\";\n label for=\"new-thread-text\" { \"text\" }\n@@ -946,10 +974,22 @@ fn new_thread_form_for_room(nav: &ThreadNav, show: bool) -> Markup {\n section class=\"compose\" id=\"room-new-thread-compose\" hidden {\n h3 { \"new thread in this room\" }\n div id=\"room-new-thread-errors\" {}\n- form id=\"room-new-thread-form\" method=\"POST\" action=\"/post\" data-check-action=\"/post/check\" {\n- input type=\"hidden\" name=\"room\" value=(nav.room_wire.clone());\n- input type=\"hidden\" name=\"error_target\" value=\"room-new-thread-errors\";\n- input type=\"hidden\" name=\"form_id\" value=\"room-new-thread-form\";\n+ form id=\"room-new-thread-form\" method=\"POST\" action=\"/ui\" data-check-action=\"/ui\" data-check-rpc=(template_json_compact(&json!({\n+ \"action\": \"check_ingest\",\n+ \"room\": nav.room_wire,\n+ \"thread_tag\": {\"$form\": \"thread_tag\"},\n+ \"text\": {\"$form\": \"text\"},\n+ \"error_target\": \"room-new-thread-errors\",\n+ \"form_id\": \"room-new-thread-form\",\n+ })).unwrap()) {\n+ input type=\"hidden\" name=(UI_RPC_FIELD) value=(template_json_compact(&json!({\n+ \"action\": \"post_ingest\",\n+ \"room\": nav.room_wire,\n+ \"thread_tag\": {\"$form\": \"thread_tag\"},\n+ \"text\": {\"$form\": \"text\"},\n+ \"error_target\": \"room-new-thread-errors\",\n+ \"form_id\": \"room-new-thread-form\",\n+ })).unwrap());\n label for=\"room-new-tag\" { \"thread tag\" }\n input type=\"text\" id=\"room-new-tag\" name=\"thread_tag\" pattern=\"[a-z0-9_\\\\-]{1,64}\" required;\n textarea name=\"text\" rows=\"4\" placeholder=\"First post body…\" required {}\ndiff --git a/server/src/html/mod.rs b/server/src/html/mod.rs\nindex a4fd2dd60ac283f7eb9b2e64522501b21d1e27e9..63f723804be511af80b5c13e792109754b606fce 100644\n--- a/server/src/html/mod.rs\n+++ b/server/src/html/mod.rs\n@@ -373,9 +373,14 @@ script { (maud::PreEscaped(r#\"\n async function runFormCheck(form) {\n const action = form.getAttribute('data-check-action');\n if (!action) return;\n+ const fd = new URLSearchParams(new FormData(form));\n+ const checkRpc = form.getAttribute('data-check-rpc');\n+ if (checkRpc) {\n+ fd.set('__rpc__', checkRpc);\n+ }\n const resp = await fetch(action, {\n method: 'POST',\n- body: new URLSearchParams(new FormData(form)),\n+ body: fd,\n headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n credentials: 'same-origin',\n });\ndiff --git a/server/src/html/ui_action.rs b/server/src/html/ui_action.rs\nindex 3bc69ecbb7e16a59d2d393b2d59f9cd3fedb12b1..73180737db8ba8d16fb7b42b790e44d2d7b796f5 100644\n--- a/server/src/html/ui_action.rs\n+++ b/server/src/html/ui_action.rs\n@@ -14,7 +14,7 @@ pub const UI_RPC_FIELD: &str = \"__rpc__\";\n #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]\n #[serde(tag = \"action\", rename_all = \"snake_case\")]\n pub enum HtmlUiAction {\n- /// Same semantics as `POST /post` (forum ingest).\n+ /// Forum ingest via `POST /ui`.\n PostIngest {\n room: String,\n thread_tag: String,\n@@ -24,7 +24,7 @@ pub enum HtmlUiAction {\n #[serde(default)]\n form_id: Option,\n },\n- /// Same as `POST /post/check`.\n+ /// DSL check / validation via `POST /ui`.\n CheckIngest {\n room: String,\n thread_tag: String,\n@@ -34,7 +34,7 @@ pub enum HtmlUiAction {\n #[serde(default)]\n form_id: Option,\n },\n- /// Same as `POST /post/redact`.\n+ /// Author redacts own post via `POST /ui`.\n RedactPost {\n post_id: String,\n },\ndiff --git a/server/src/lib.rs b/server/src/lib.rs\nindex 5f5b6b0f01b29350c7415e5801ec5caa74f0b452..c28c2335d23080671e71155ea3fb023f9b8d98c4 100644\n--- a/server/src/lib.rs\n+++ b/server/src/lib.rs\n@@ -31,9 +31,6 @@ pub fn create_app(state: AppState) -> Router {\n .route(\"/\", get(crate::html::home))\n .route(\"/login\", get(api::get_web_login))\n .route(\"/logout\", get(api::get_logout))\n- .route(\"/post\", post(api::post_web_ingest))\n- .route(\"/post/redact\", post(api::post_web_redact))\n- .route(\"/post/check\", post(api::check_web_ingest))\n .route(\"/ui\", post(api::post_ui_html))\n .route(\"/theme\", post(crate::html::post_theme))\n .route(\"/sse\", get(api::get_html_stream))\n@@ -64,6 +61,7 @@ pub fn create_app(state: AppState) -> Router {\n )\n .route(\"/t/:tag/:index\", get(crate::html::thread_post_view))\n .route(\"/t/:tag\", get(crate::html::thread_view))\n+ // TODO DELETE THESE AND USE __RPC__ PATTERN\n .route(\n \"/r/:room_short/:room_slug/t/:thread_tag/:index/expand\",\n get(crate::html::room_thread_post_expand),\ndiff --git a/server/tests/integration.rs b/server/tests/integration.rs\nindex d86de2dd446516807a3c8a607aa88787d1b136b0..831722430d6b41308e3f03d485cd62f887ace799 100644\n--- a/server/tests/integration.rs\n+++ b/server/tests/integration.rs\n@@ -21,6 +21,30 @@ fn test_bearer() -> String {\n format!(\"slug_{token_id}_{secret}\")\n }\n \n+/// Compact JSON for `POST /ui` (`HtmlUiAction::PostIngest`).\n+fn ui_post_ingest_rpc(room: &str, thread_tag: &str, text: &str) -> String {\n+ serde_json::json!({\n+ \"action\": \"post_ingest\",\n+ \"room\": room,\n+ \"thread_tag\": thread_tag,\n+ \"text\": text,\n+ })\n+ .to_string()\n+}\n+\n+/// Compact JSON for `POST /ui` (`HtmlUiAction::CheckIngest`).\n+fn ui_check_ingest_rpc(room: &str, thread_tag: &str, text: &str, error_target: &str) -> String {\n+ serde_json::json!({\n+ \"action\": \"check_ingest\",\n+ \"room\": room,\n+ \"thread_tag\": thread_tag,\n+ \"text\": text,\n+ \"error_target\": error_target,\n+ \"form_id\": \"thread-compose-form\",\n+ })\n+ .to_string()\n+}\n+\n /// `commands` is a JSON array of RPC commands (`RpcBatch` is a transparent `Vec`).\n async fn rpc_batch(\n client: &reqwest::Client,\n@@ -343,14 +367,11 @@ async fn test_private_room_thread_urls_use_t_segment() {\n .to_string();\n let (room_short, room_slug) = room_id.split_once('/').unwrap();\n \n+ let rpc = ui_post_ingest_rpc(&room_id, \"main-thread\", \"private post via web\");\n let post = client\n- .post(format!(\"http://{addr}/post\"))\n+ .post(format!(\"http://{addr}/ui\"))\n .header(\"Authorization\", format!(\"Bearer {bearer}\"))\n- .form(&[\n- (\"room\", room_id.as_str()),\n- (\"thread_tag\", \"main-thread\"),\n- (\"text\", \"private post via web\"),\n- ])\n+ .form(&[(\"__rpc__\", rpc.as_str())])\n .send()\n .await\n .unwrap();\n@@ -404,14 +425,15 @@ async fn test_private_room_post_links_use_private_garden_routes() {\n .to_string();\n let (room_short, room_slug) = room_id.split_once('/').unwrap();\n \n+ let rpc = ui_post_ingest_rpc(\n+ &room_id,\n+ \"garden-thread\",\n+ \"~/secret/item {classified}\\n~/secret/other {other body}\\n~/secret/item 3:1 ~/secret/other {because}\\n\",\n+ );\n let post = client\n- .post(format!(\"http://{addr}/post\"))\n+ .post(format!(\"http://{addr}/ui\"))\n .header(\"Authorization\", format!(\"Bearer {bearer}\"))\n- .form(&[\n- (\"room\", room_id.as_str()),\n- (\"thread_tag\", \"garden-thread\"),\n- (\"text\", \"~/secret/item {classified}\\n~/secret/other {other body}\\n~/secret/item 3:1 ~/secret/other {because}\\n\"),\n- ])\n+ .form(&[(\"__rpc__\", rpc.as_str())])\n .send()\n .await\n .unwrap();\n@@ -449,15 +471,11 @@ async fn test_post_check_returns_targeted_js_error_for_missing_thread_tag() {\n .unwrap();\n let bearer = test_bearer();\n \n+ let rpc = ui_check_ingest_rpc(\"public\", \"\", \"hello\", \"thread-compose-errors\");\n let resp = client\n- .post(format!(\"http://{addr}/post/check\"))\n+ .post(format!(\"http://{addr}/ui\"))\n .header(\"Authorization\", format!(\"Bearer {bearer}\"))\n- .form(&[\n- (\"room\", \"public\"),\n- (\"thread_tag\", \"\"),\n- (\"text\", \"hello\"),\n- (\"error_target\", \"thread-compose-errors\"),\n- ])\n+ .form(&[(\"__rpc__\", rpc.as_str())])\n .send()\n .await\n .unwrap();\n@@ -553,14 +571,11 @@ async fn test_sse_stream_emits_evalable_js_after_post() {\n .unwrap();\n assert!(sse_resp.status().is_success());\n \n+ let rpc = ui_post_ingest_rpc(&room_id, \"live-thread\", \"hello over sse\");\n let _post = client\n- .post(format!(\"http://{addr}/post\"))\n+ .post(format!(\"http://{addr}/ui\"))\n .header(\"Authorization\", format!(\"Bearer {bearer}\"))\n- .form(&[\n- (\"room\", room_id.as_str()),\n- (\"thread_tag\", \"live-thread\"),\n- (\"text\", \"hello over sse\"),\n- ])\n+ .form(&[(\"__rpc__\", rpc.as_str())])\n .send()\n .await\n .unwrap();\ndiff --git a/test/walkthrough_fixture.clj b/test/walkthrough_fixture.clj\nindex ec7914464b1a0389e40c5be53af59cf8ed82b09e..4b814822029c913e38151ca53848d1c64fb58373 100644\n--- a/test/walkthrough_fixture.clj\n+++ b/test/walkthrough_fixture.clj\n@@ -52,22 +52,28 @@\n \"~/secret/item {classified}\\n\"\n \"~/secret/other {secondary}\\n\"\n \"~/secret/item 3:1 ~/secret/other {because}\\n\")\n+ rpc (json/generate-string\n+ {:action \"post_ingest\"\n+ :room room-id\n+ :thread_tag \"walkthrough-thread\"\n+ :text wall-text})\n post-resp (oauth/http-post-form\n- (str base-url \"/post\")\n- {:room room-id\n- :thread_tag \"walkthrough-thread\"\n- :text wall-text}\n+ (str base-url \"/ui\")\n+ {:__rpc__ rpc}\n :headers {\"Authorization\" (str \"Bearer \" alice-token)})]\n (assert! (= 200 (:status post-resp)) \"seed post must succeed\"))\n (let [bob-reply (str \"Bob here — reply with another long block so the thread has multiple cards.\\n\\n\"\n \"Paragraph two: repeating slugs ~/secret/item and ~/secret/other for cross-post link styling. \"\n \"If everything wraps cleanly, monospace pre + serif body (in craft theme) should still feel readable.\\n\\n\"\n \"More overflow: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX\\n\")\n+ bob-rpc (json/generate-string\n+ {:action \"post_ingest\"\n+ :room room-id\n+ :thread_tag \"walkthrough-thread\"\n+ :text bob-reply})\n bob-post (oauth/http-post-form\n- (str base-url \"/post\")\n- {:room room-id\n- :thread_tag \"walkthrough-thread\"\n- :text bob-reply}\n+ (str base-url \"/ui\")\n+ {:__rpc__ bob-rpc}\n :headers {\"Authorization\" (str \"Bearer \" bob-token)})]\n (assert! (= 200 (:status bob-post)) \"seed reply post must succeed\"))\n {:users {:alice {:token alice-token}\n","role":"user"}],"model":"openai/gpt-chat-latest"}