Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
193 changes: 193 additions & 0 deletions desktop/src-tauri/src/commands/join_policy.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
use futures_util::StreamExt;
use serde_json::Value;
use std::time::Duration;
use url::Url;

// Each relay policy document is capped at 256 KiB before JSON encoding. Four
// MiB covers two maximally escaped documents plus the response envelope.
const MAX_JOIN_POLICY_RESPONSE_BYTES: usize = 4 * 1024 * 1024;
const JOIN_POLICY_REQUEST_TIMEOUT: Duration = Duration::from_secs(15);

fn join_policy_url(relay_url: &str) -> Result<Url, String> {
let mut url = Url::parse(relay_url.trim()).map_err(|_| "invalid relay URL".to_string())?;
let http_scheme = match url.scheme() {
"wss" => "https",
"ws" => "http",
_ => return Err("relay URL must use ws:// or wss://".to_string()),
};
url.set_scheme(http_scheme)
.map_err(|_| "invalid relay URL scheme".to_string())?;

if !url.username().is_empty() || url.password().is_some() {
return Err("relay URL must not contain credentials".to_string());
}

let base_path = url.path().trim_end_matches('/');
url.set_path(&format!("{base_path}/api/join-policy"));
url.set_query(None);
url.set_fragment(None);
Ok(url)
}

/// Fetch an arbitrary relay's optional join policy through native networking.
#[tauri::command]
pub async fn fetch_join_policy(relay_url: String) -> Result<Option<Value>, String> {
let url = join_policy_url(&relay_url)?;
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|error| format!("failed to build join policy client: {error}"))?;
let response = client
.get(url)
.timeout(JOIN_POLICY_REQUEST_TIMEOUT)
.send()
.await
.map_err(|error| format!("join policy request failed: {error}"))?;

if response.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
if !response.status().is_success() {
return Err(format!("HTTP {}", response.status().as_u16()));
}

let body = read_join_policy_json(response).await?;
Ok(body
.get("policy")
.filter(|policy| !policy.is_null())
.cloned())
}

async fn read_join_policy_json(response: reqwest::Response) -> Result<Value, String> {
if response
.content_length()
.is_some_and(|length| length > MAX_JOIN_POLICY_RESPONSE_BYTES as u64)
{
return Err("relay returned oversized join policy".to_string());
}

let mut stream = response.bytes_stream();
let mut bytes = Vec::new();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|error| format!("reading join policy failed: {error}"))?;
if bytes.len().saturating_add(chunk.len()) > MAX_JOIN_POLICY_RESPONSE_BYTES {
return Err("relay returned oversized join policy".to_string());
}
bytes.extend_from_slice(&chunk);
}

serde_json::from_slice(&bytes).map_err(|_| "relay returned malformed join policy".to_string())
}

#[cfg(test)]
mod tests {
use super::*;
use axum::{
body::{Body, Bytes},
http::Response,
response::Redirect,
routing::get,
Json, Router,
};
use std::convert::Infallible;

async fn test_relay(router: Router) -> String {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, router).await.unwrap();
});
format!("ws://{address}")
}

#[test]
fn converts_relay_urls_to_join_policy_urls() {
assert_eq!(
join_policy_url("wss://relay.example.com/")
.unwrap()
.as_str(),
"https://relay.example.com/api/join-policy"
);
assert_eq!(
join_policy_url("ws://localhost:3000/base")
.unwrap()
.as_str(),
"http://localhost:3000/base/api/join-policy"
);
}

#[test]
fn rejects_non_relay_schemes_and_credentials() {
assert!(join_policy_url("https://relay.example.com").is_err());
assert!(join_policy_url("wss://user:secret@relay.example.com").is_err());
}

#[tokio::test]
async fn reads_an_optional_policy_without_webview_cors() {
let relay_url = test_relay(Router::new().route(
"/api/join-policy",
get(|| async {
Json(serde_json::json!({
"policy": {
"terms_markdown": "# Terms",
"age_attestation_required": true,
"version": "v1"
}
}))
}),
))
.await;

let policy = fetch_join_policy(relay_url).await.unwrap().unwrap();
assert_eq!(policy["version"], "v1");
assert_eq!(policy["age_attestation_required"], true);
}

#[tokio::test]
async fn refuses_join_policy_redirects() {
let relay_url = test_relay(Router::new().route(
"/api/join-policy",
get(|| async { Redirect::temporary("http://127.0.0.1:1/private") }),
))
.await;

assert_eq!(fetch_join_policy(relay_url).await.unwrap_err(), "HTTP 307");
}

#[tokio::test]
async fn rejects_declared_oversized_join_policy() {
let relay_url = test_relay(Router::new().route(
"/api/join-policy",
get(|| async {
Response::builder()
.body(Body::from(vec![b'x'; MAX_JOIN_POLICY_RESPONSE_BYTES + 1]))
.unwrap()
}),
))
.await;

assert_eq!(
fetch_join_policy(relay_url).await.unwrap_err(),
"relay returned oversized join policy"
);
}

#[tokio::test]
async fn rejects_chunked_oversized_join_policy() {
let relay_url = test_relay(Router::new().route(
"/api/join-policy",
get(|| async {
let chunk = Bytes::from(vec![b'x'; MAX_JOIN_POLICY_RESPONSE_BYTES + 1]);
Body::from_stream(futures_util::stream::once(async move {
Ok::<_, Infallible>(chunk)
}))
}),
))
.await;

assert_eq!(
fetch_join_policy(relay_url).await.unwrap_err(),
"relay returned oversized join policy"
);
}
}
2 changes: 2 additions & 0 deletions desktop/src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ mod export_util;
mod global_agent_config;
mod identity;
mod identity_archive;
mod join_policy;
mod legacy_storage;
mod link_preview;
pub(crate) mod media;
Expand Down Expand Up @@ -78,6 +79,7 @@ pub use engrams::*;
pub use global_agent_config::*;
pub use identity::*;
pub use identity_archive::*;
pub use join_policy::*;
pub use legacy_storage::*;
pub use link_preview::*;
pub use media::*;
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -895,6 +895,7 @@ pub fn run() {
validate_repos_dir,
get_active_workspace,
fetch_workspace_icon,
fetch_join_policy,
set_prevent_sleep_active,
get_agent_memory,
relay_reconnect_hook,
Expand Down
4 changes: 2 additions & 2 deletions desktop/src/features/communities/ui/CommunityEditForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export function CommunityEditForm({

let cancelled = false;
const timeoutId = window.setTimeout(() => {
void getJoinPolicy(normalizedUrl)
void getJoinPolicy(normalizedUrl, "native")
Comment thread
johnmatthewtennant marked this conversation as resolved.
.then((policy) => {
if (cancelled || !policy) return;
setJoinPolicy(policy);
Expand Down Expand Up @@ -105,7 +105,7 @@ export function CommunityEditForm({

if (joinPolicyRequired) {
try {
const policy = await getJoinPolicy(normalizedUrl);
const policy = await getJoinPolicy(normalizedUrl, "native");
if (!policy) {
onSubmit(trimmedName, normalizedUrl);
return;
Expand Down
6 changes: 3 additions & 3 deletions desktop/src/features/onboarding/ui/InviteRedeemForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ export function InviteRedeemForm({
const code = parsedInvite?.code;
let cancelled = false;
const timeoutId = window.setTimeout(() => {
void getJoinPolicy(relayWsUrl)
void getJoinPolicy(relayWsUrl, code ? "webview" : "native")
Comment thread
johnmatthewtennant marked this conversation as resolved.
.then((policy) => {
if (cancelled || !policy) return;
setJoinPolicy(policy);
Expand Down Expand Up @@ -156,7 +156,7 @@ export function InviteRedeemForm({
setPolicyError(null);
setIsLoadingPolicy(true);
try {
const policy = await getJoinPolicy(normalizedRelayUrl);
const policy = await getJoinPolicy(normalizedRelayUrl, "native");
if (!policy) {
onConnect?.(normalizedRelayUrl, apiToken.trim() || undefined);
return;
Expand Down Expand Up @@ -205,7 +205,7 @@ export function InviteRedeemForm({
setPolicyError(null);
setIsLoadingPolicy(true);
try {
const policy = await getJoinPolicy(relayWsUrl);
const policy = await getJoinPolicy(relayWsUrl, "webview");
if (!policy) {
onRedeem(relayWsUrl, parsedInvite.code);
return;
Expand Down
37 changes: 33 additions & 4 deletions desktop/src/shared/api/invites.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ test("getJoinPolicy maps relay-hosted Markdown and age requirements", async () =
{ status: 200 },
),
async () => {
assert.deepEqual(await getJoinPolicy("wss://relay.example"), {
assert.deepEqual(await getJoinPolicy("wss://relay.example", "webview"), {
termsMarkdown: "# Terms",
privacyMarkdown: "# Privacy",
ageAttestationRequired: true,
Expand All @@ -40,15 +40,44 @@ test("getJoinPolicy maps relay-hosted Markdown and age requirements", async () =

test("getJoinPolicy preserves opt-in behavior for unconfigured and older relays", async () => {
await withFetch(new Response(JSON.stringify({}), { status: 200 }), async () =>
assert.equal(await getJoinPolicy("wss://relay.example"), null),
assert.equal(await getJoinPolicy("wss://relay.example", "webview"), null),
);
await withFetch(new Response(null, { status: 404 }), async () =>
assert.equal(await getJoinPolicy("wss://relay.example"), null),
assert.equal(await getJoinPolicy("wss://relay.example", "webview"), null),
);
});

test("getJoinPolicy fails closed on a policy endpoint error", async () => {
await withFetch(new Response(null, { status: 503 }), async () =>
assert.rejects(getJoinPolicy("wss://relay.example"), /HTTP 503/),
assert.rejects(getJoinPolicy("wss://relay.example", "webview"), /HTTP 503/),
);
});

test("getJoinPolicy maps the native command response", async () => {
const previousWindow = globalThis.window;
globalThis.window = {
__TAURI_INTERNALS__: {
invoke(command, args) {
assert.equal(command, "fetch_join_policy");
assert.deepEqual(args, { relayUrl: "wss://relay.example" });
return Promise.resolve({
terms_markdown: "# Terms",
privacy_markdown: "# Privacy",
age_attestation_required: true,
version: "policy-v1",
});
},
},
};

try {
assert.deepEqual(await getJoinPolicy("wss://relay.example", "native"), {
termsMarkdown: "# Terms",
privacyMarkdown: "# Privacy",
ageAttestationRequired: true,
version: "policy-v1",
});
} finally {
globalThis.window = previousWindow;
}
});
52 changes: 34 additions & 18 deletions desktop/src/shared/api/invites.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { relayHttpFromWs } from "@/shared/api/inviteHelpers";
import { getRelayHttpUrl, signRelayEvent } from "@/shared/api/tauri";
import {
getRelayHttpUrl,
invokeTauri,
signRelayEvent,
} from "@/shared/api/tauri";

// Relay invite data layer. Both endpoints are NIP-98-authed HTTP POSTs
// (mirrors the read path in moderation.ts, plus the payload tag the relay
Expand Down Expand Up @@ -124,26 +128,38 @@ export function isJoinPolicyDiscoveryCandidate(relayWsUrl: string): boolean {
/** Fetch relay-hosted policy content for any join surface. */
export async function getJoinPolicy(
relayWsUrl: string,
transport: "native" | "webview",
): Promise<JoinPolicy | null> {
const base = relayHttpFromWs(relayWsUrl);
const response = await fetch(`${base.replace(/\/+$/, "")}/api/join-policy`);
// Relays predating join-policy support have no configured policy.
if (response.status === 404) return null;
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const raw = (await response.json()) as {
policy?: {
terms_markdown?: string;
privacy_markdown?: string;
age_attestation_required: boolean;
version: string;
};
type RawJoinPolicy = {
terms_markdown?: string;
privacy_markdown?: string;
age_attestation_required: boolean;
version: string;
};
return raw.policy
let raw: RawJoinPolicy | null;
if (transport === "native") {
raw = await invokeTauri<RawJoinPolicy | null>("fetch_join_policy", {
relayUrl: relayWsUrl,
});
} else {
const base = relayHttpFromWs(relayWsUrl);
const response = await fetch(`${base.replace(/\/+$/, "")}/api/join-policy`);
// Relays predating join-policy support have no configured policy.
if (response.status === 404) return null;
if (!response.ok) throw new Error(`HTTP ${response.status}`);
raw =
(
(await response.json()) as {
policy?: RawJoinPolicy;
}
).policy ?? null;
}
return raw
? {
termsMarkdown: raw.policy.terms_markdown,
privacyMarkdown: raw.policy.privacy_markdown,
ageAttestationRequired: raw.policy.age_attestation_required,
version: raw.policy.version,
termsMarkdown: raw.terms_markdown,
privacyMarkdown: raw.privacy_markdown,
ageAttestationRequired: raw.age_attestation_required,
version: raw.version,
}
: null;
}
Expand Down
Loading
Loading