mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(join-policy): require legal consent on hosted invites (#1987)
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
co-authored by
npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757
parent
c540ec9678
commit
2e1577f76f
@@ -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<Arc<AppState>>) -> Json<Value> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `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<Arc<AppState>>,
|
||||
) -> Result<Html<String>, (StatusCode, Json<Value>)> {
|
||||
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<Arc<AppState>>,
|
||||
) -> Result<Html<String>, (StatusCode, Json<Value>)> {
|
||||
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<Html<String>, (StatusCode, Json<Value>)> {
|
||||
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!(
|
||||
"<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n\
|
||||
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\
|
||||
<title>{escaped_title}</title>\n\
|
||||
<style>body{{max-width:42rem;margin:2rem auto;padding:0 1rem;\
|
||||
font-family:system-ui,sans-serif;line-height:1.6}}</style>\n\
|
||||
</head>\n<body>\n{body}</body>\n</html>\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// Exchange explicit policy acceptance for a short-lived, invite-bound receipt.
|
||||
pub async fn accept_policy(
|
||||
State(state): State<Arc<AppState>>,
|
||||
@@ -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<script>alert(1)</script>",
|
||||
);
|
||||
assert!(page.contains("<title>Terms of Service</title>"), "{page}");
|
||||
assert!(page.contains("<h1>Terms</h1>"), "{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("<script>"), "{page}");
|
||||
assert!(
|
||||
page.contains("<script>alert(1)</script>"),
|
||||
"{page}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The document routes are public (no NIP-98) and 404 until configured,
|
||||
/// exactly like the JSON policy endpoint they sit beside.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn join_policy_document_pages_serve_configured_markdown() {
|
||||
let host = format!("invites-docs-{}.example", Uuid::new_v4().simple());
|
||||
let Some(state) = invite_test_state(&host).await else {
|
||||
return;
|
||||
};
|
||||
|
||||
let get_page = |state: Arc<crate::state::AppState>, path: &'static str| {
|
||||
let host = host.clone();
|
||||
async move {
|
||||
build_router(state)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri(path)
|
||||
.header(header::HOST, host)
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response")
|
||||
}
|
||||
};
|
||||
|
||||
// Unconfigured relay: both documents 404.
|
||||
let response = get_page(state.clone(), "/api/join-policy/terms").await;
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
let response = get_page(state.clone(), "/api/join-policy/privacy").await;
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
|
||||
// Configure terms only — terms serves HTML, privacy still 404s.
|
||||
let mut state_inner = (*state).clone();
|
||||
let mut config = state_inner.config.as_ref().clone();
|
||||
config.join_policy = Some(crate::config::JoinPolicyConfig {
|
||||
terms_markdown: Some("# Terms\n\nNo funny business.".to_string()),
|
||||
privacy_markdown: None,
|
||||
age_attestation_required: false,
|
||||
version: "v".repeat(64),
|
||||
});
|
||||
state_inner.config = Arc::new(config);
|
||||
let state = Arc::new(state_inner);
|
||||
|
||||
let response = get_page(state.clone(), "/api/join-policy/terms").await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
assert!(content_type.starts_with("text/html"), "{content_type}");
|
||||
let bytes = to_bytes(response.into_body(), 1024 * 1024)
|
||||
.await
|
||||
.expect("read body");
|
||||
let page = String::from_utf8(bytes.to_vec()).expect("utf8");
|
||||
assert!(page.contains("<h1>Terms</h1>"), "{page}");
|
||||
assert!(page.contains("No funny business."), "{page}");
|
||||
|
||||
let response = get_page(state, "/api/join-policy/privacy").await;
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +84,16 @@ pub fn build_router(state: Arc<AppState>) -> Router {
|
||||
// Relay invites: mint (owner/admin) + claim (membership-gate exempt)
|
||||
.route("/api/invites", post(api::invites::mint_invite))
|
||||
.route("/api/join-policy", get(api::invites::join_policy))
|
||||
// Policy documents as standalone pages — desktop opens these in the
|
||||
// system browser instead of rendering the Markdown in-app.
|
||||
.route(
|
||||
"/api/join-policy/terms",
|
||||
get(api::invites::join_policy_terms),
|
||||
)
|
||||
.route(
|
||||
"/api/join-policy/privacy",
|
||||
get(api::invites::join_policy_privacy),
|
||||
)
|
||||
.route(
|
||||
"/api/invites/accept-policy",
|
||||
post(api::invites::accept_policy),
|
||||
|
||||
@@ -5,6 +5,8 @@ import * as React from "react";
|
||||
import Markdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
import { InviteJoinPolicyNotice } from "./InviteJoinPolicyNotice";
|
||||
|
||||
const DOWNLOAD_URL = "https://github.com/block/buzz/releases/latest";
|
||||
type JoinPolicy = {
|
||||
terms_markdown?: string;
|
||||
@@ -24,6 +26,7 @@ export function InvitePage({ code }: { code: string }) {
|
||||
);
|
||||
const [document, setDocument] = React.useState<PolicyDocument | null>(null);
|
||||
const [ageConfirmed, setAgeConfirmed] = React.useState(false);
|
||||
const [agreementConfirmed, setAgreementConfirmed] = React.useState(false);
|
||||
const [opening, setOpening] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -64,7 +67,18 @@ export function InvitePage({ code }: { code: string }) {
|
||||
const disabled =
|
||||
policy === undefined ||
|
||||
opening ||
|
||||
Boolean(policy?.age_attestation_required && !ageConfirmed);
|
||||
Boolean(policy?.age_attestation_required && !ageConfirmed) ||
|
||||
Boolean(
|
||||
policy &&
|
||||
(policy.terms_markdown || policy.privacy_markdown) &&
|
||||
!agreementConfirmed,
|
||||
);
|
||||
const hasPolicyRequirements = Boolean(
|
||||
policy &&
|
||||
(policy.age_attestation_required ||
|
||||
policy.terms_markdown ||
|
||||
policy.privacy_markdown),
|
||||
);
|
||||
const showDocument = (title: string, markdown: string) =>
|
||||
setDocument({ title, markdown });
|
||||
|
||||
@@ -88,23 +102,32 @@ export function InvitePage({ code }: { code: string }) {
|
||||
</h1>
|
||||
<p className="mt-9 font-mono text-lg text-black/70">{host}</p>
|
||||
|
||||
{policy?.age_attestation_required && (
|
||||
<label className="mt-9 flex max-w-md cursor-pointer items-start gap-3 text-left text-sm text-black/70">
|
||||
<input
|
||||
className="mt-0.5 h-4 w-4 accent-black"
|
||||
type="checkbox"
|
||||
checked={ageConfirmed}
|
||||
onChange={(event) => setAgeConfirmed(event.target.checked)}
|
||||
/>
|
||||
<span>I am 18 years of age or older.</span>
|
||||
</label>
|
||||
)}
|
||||
<div className={policy?.age_attestation_required ? "mt-5" : "mt-9"}>
|
||||
<div
|
||||
className={`grid w-full max-w-md overflow-hidden transition-[grid-template-rows,margin,opacity,transform] duration-[220ms] [transition-timing-function:cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none ${
|
||||
hasPolicyRequirements
|
||||
? "mt-9 -mb-4 grid-rows-[1fr] opacity-100 translate-y-0"
|
||||
: "m-0 grid-rows-[0fr] opacity-0 -translate-y-1"
|
||||
}`}
|
||||
>
|
||||
<div className="min-h-0 overflow-hidden">
|
||||
{policy && hasPolicyRequirements ? (
|
||||
<InviteJoinPolicyNotice
|
||||
ageConfirmed={ageConfirmed}
|
||||
agreementConfirmed={agreementConfirmed}
|
||||
onAgeConfirmedChange={setAgeConfirmed}
|
||||
onAgreementConfirmedChange={setAgreementConfirmed}
|
||||
onShowDocument={showDocument}
|
||||
policy={policy}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-9 w-full max-w-md">
|
||||
{policy === null ? (
|
||||
<Button
|
||||
asChild
|
||||
className="bg-black text-white hover:bg-black/90 focus-visible:ring-black"
|
||||
size="lg"
|
||||
className="h-10 w-full bg-black text-white hover:bg-black/90 focus-visible:ring-black"
|
||||
>
|
||||
<a
|
||||
href={`buzz://join?relay=${encodeURIComponent(relay)}&code=${encodeURIComponent(code)}`}
|
||||
@@ -114,8 +137,7 @@ export function InvitePage({ code }: { code: string }) {
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="bg-black text-white hover:bg-black/90 focus-visible:ring-black disabled:cursor-not-allowed disabled:bg-black/30 disabled:text-white/70"
|
||||
size="lg"
|
||||
className="h-10 w-full bg-black text-white hover:bg-black/90 focus-visible:ring-black disabled:cursor-not-allowed disabled:bg-black/30 disabled:text-white/70"
|
||||
disabled={disabled}
|
||||
onClick={openInvite}
|
||||
>
|
||||
@@ -123,41 +145,6 @@ export function InvitePage({ code }: { code: string }) {
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{policy && (policy.terms_markdown || policy.privacy_markdown) && (
|
||||
<p className="mt-4 max-w-md text-xs text-black/60">
|
||||
By proceeding you agree to the Buzz{" "}
|
||||
{policy.terms_markdown && (
|
||||
<button
|
||||
className="text-black underline-offset-4 hover:text-black/70 hover:underline focus-visible:underline"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
showDocument(
|
||||
"Terms of Service",
|
||||
policy.terms_markdown ?? "",
|
||||
)
|
||||
}
|
||||
>
|
||||
Terms of Service
|
||||
</button>
|
||||
)}
|
||||
{policy.terms_markdown && policy.privacy_markdown && " and "}
|
||||
{policy.privacy_markdown && (
|
||||
<button
|
||||
className="text-black underline-offset-4 hover:text-black/70 hover:underline focus-visible:underline"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
showDocument(
|
||||
"Privacy Policy",
|
||||
policy.privacy_markdown ?? "",
|
||||
)
|
||||
}
|
||||
>
|
||||
Privacy Policy
|
||||
</button>
|
||||
)}
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="flex h-[3.125rem] items-center justify-center rounded-2xl bg-white text-sm text-black/60">
|
||||
Don't have the app?{" "}
|
||||
|
||||
Reference in New Issue
Block a user