diff --git a/Cargo.lock b/Cargo.lock index 6091f52b63..598142154f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1147,6 +1147,7 @@ dependencies = [ "opentelemetry-otlp 0.32.0", "opentelemetry_sdk 0.32.1", "postcard", + "pulldown-cmark", "rand 0.10.1", "redis", "reqwest 0.13.4", @@ -1520,7 +1521,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2158,7 +2159,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 2.0.117", ] [[package]] @@ -2369,7 +2370,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2607,7 +2608,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2990,8 +2991,8 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.1.3", - "windows-result 0.3.4", + "windows-link 0.2.1", + "windows-result 0.4.1", ] [[package]] @@ -5549,7 +5550,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6733,6 +6734,24 @@ version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3" +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags 2.13.0", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + [[package]] name = "pxfm" version = "0.1.29" @@ -6838,7 +6857,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -7485,7 +7504,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7544,7 +7563,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7807,7 +7826,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -8704,7 +8723,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -10003,7 +10022,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/crates/buzz-relay/Cargo.toml b/crates/buzz-relay/Cargo.toml index 36cc71dcb0..31eac113a1 100644 --- a/crates/buzz-relay/Cargo.toml +++ b/crates/buzz-relay/Cargo.toml @@ -77,6 +77,7 @@ moka = { workspace = true } metrics = { workspace = true } metrics-exporter-prometheus = { workspace = true } metrics-util = { workspace = true } +pulldown-cmark = { version = "0.13.4", default-features = false, features = ["html"] } [features] dev = ["buzz-auth/dev"] diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs index 7d004c08c9..cf7ee3a6eb 100644 --- a/crates/buzz-relay/src/api/invites.rs +++ b/crates/buzz-relay/src/api/invites.rs @@ -19,7 +19,7 @@ use std::time::Duration; use axum::{ extract::State, http::{HeaderMap, StatusCode}, - response::Json, + response::{Html, Json}, }; use serde::Deserialize; use serde_json::Value; @@ -86,6 +86,78 @@ pub async fn join_policy(State(state): State>) -> Json { } } +/// `GET /api/join-policy/terms` — Terms of Service as a standalone HTML page. +/// +/// Serves the operator-configured Markdown as a real browser page so desktop +/// clients can hand the link to the system browser instead of rendering the +/// document inside the webview (which requires app chrome the onboarding +/// surfaces don't have). 404 when no terms document is configured. +pub async fn join_policy_terms( + State(state): State>, +) -> Result, (StatusCode, Json)> { + policy_document_page(&state, "Terms of Service", |policy| { + policy.terms_markdown.as_deref() + }) +} + +/// `GET /api/join-policy/privacy` — Privacy Policy as a standalone HTML page. +pub async fn join_policy_privacy( + State(state): State>, +) -> Result, (StatusCode, Json)> { + policy_document_page(&state, "Privacy Policy", |policy| { + policy.privacy_markdown.as_deref() + }) +} + +fn policy_document_page( + state: &AppState, + title: &str, + select: impl Fn(&crate::config::JoinPolicyConfig) -> Option<&str>, +) -> Result, (StatusCode, Json)> { + let markdown = state + .config + .join_policy + .as_ref() + .and_then(select) + .ok_or_else(|| api_error(StatusCode::NOT_FOUND, "join_policy_not_configured"))?; + Ok(Html(render_policy_document(title, markdown))) +} + +/// Render operator Markdown into a minimal self-contained HTML page. +/// +/// Raw HTML embedded in the Markdown is escaped and rendered as text — the +/// operator authors a policy document, not a web page, and this keeps the +/// endpoint from serving arbitrary operator-controlled markup. +fn render_policy_document(title: &str, markdown: &str) -> String { + use pulldown_cmark::{html, Event, Parser}; + + let mut body = String::new(); + html::push_html( + &mut body, + Parser::new(markdown).map(|event| match event { + Event::Html(raw) => Event::Text(raw.into_string().into()), + Event::InlineHtml(raw) => Event::Text(raw.into_string().into()), + other => other, + }), + ); + + // Titles are fixed literals today; escape anyway so a future caller + // can't accidentally inject markup through this seam. + let escaped_title = title + .replace('&', "&") + .replace('<', "<") + .replace('>', ">"); + + format!( + "\n\n\n\n\ + \n\ + {escaped_title}\n\ + \n\ + \n\n{body}\n\n" + ) +} + /// Exchange explicit policy acceptance for a short-lived, invite-bound receipt. pub async fn accept_policy( State(state): State>, @@ -900,4 +972,87 @@ mod tests { let response = post_json(state, &host_b, "/api/invites/claim", &joiner, body).await; assert_eq!(response.status(), StatusCode::FORBIDDEN); } + + #[test] + fn policy_document_renders_markdown_and_escapes_raw_html() { + let page = super::render_policy_document( + "Terms of Service", + "# Terms\n\nBe kind & honest.\n\n", + ); + assert!(page.contains("Terms of Service"), "{page}"); + assert!(page.contains("

Terms

"), "{page}"); + // `&` inside prose must be entity-encoded by the HTML writer. + assert!(page.contains("Be kind & honest."), "{page}"); + // Raw HTML in operator Markdown renders as escaped text, never markup. + assert!(!page.contains("