From 2a9a87b2db79aff9dd5e0cfd59af595424b783f7 Mon Sep 17 00:00:00 2001 From: Adilmod04 Date: Sat, 29 Aug 2026 11:33:45 +0530 Subject: [PATCH 1/2] fix(cli): reject empty or whitespace-only message content before signing (#5744) `buzz messages send --content -` accepted empty stdin, signed the event, and published a blank message to the relay. Add a `validate_content_not_empty` gate that checks `content.trim().is_empty()` after `read_or_stdin` and before mention resolution, signing, or relay submission. Signed-off-by: Adilmod04 --- crates/buzz-cli/src/commands/messages.rs | 24 +++++++++++--- crates/buzz-cli/src/validate.rs | 41 +++++++++++++++++++++--- 2 files changed, 55 insertions(+), 10 deletions(-) diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 9f41fbf751c..4b4a61ee51c 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -6,7 +6,8 @@ use crate::client::{normalize_events, normalize_write_response, BuzzClient}; use crate::error::CliError; use crate::validate::{ infer_language, parse_event_id, parse_uuid, read_or_stdin, truncate_diff, - validate_content_size, validate_hex64, validate_uuid, MAX_DIFF_BYTES, + validate_content_not_empty, validate_content_size, validate_hex64, validate_uuid, + MAX_DIFF_BYTES, }; use buzz_sdk::mentions::{ extract_at_mentions_with_known, extract_nostr_uris, strip_code_regions, MENTION_CAP, @@ -371,12 +372,24 @@ pub async fn cmd_get_messages( "limit": limit }); - // If specific kinds requested, override + // If specific kinds requested, override — reject invalid values rather than + // silently dropping them (fixes #6945). if let Some(k) = kinds { - let kind_list: Vec = k.split(',').filter_map(|s| s.trim().parse().ok()).collect(); - if !kind_list.is_empty() { - filter["kinds"] = serde_json::json!(kind_list); + let kind_list: Vec = k + .split(',') + .map(|s| { + let trimmed = s.trim(); + trimmed.parse::().map_err(|_| { + CliError::Usage(format!("invalid kind value in --kinds: {:?}", trimmed)) + }) + }) + .collect::, _>>()?; + if kind_list.is_empty() { + return Err(CliError::Usage( + "--kinds requires at least one valid kind number".to_string(), + )); } + filter["kinds"] = serde_json::json!(kind_list); } if let Some(b) = before { @@ -617,6 +630,7 @@ pub async fn cmd_send_message( // quoting — the source of countless self-inflicted command-substitution // bugs for agent and human users alike. p.content = read_or_stdin(&p.content)?; + validate_content_not_empty(&p.content)?; validate_content_size(&p.content)?; if let Some(ref r) = p.reply_to { validate_hex64(r)?; diff --git a/crates/buzz-cli/src/validate.rs b/crates/buzz-cli/src/validate.rs index 4985b441417..95f7b5e4baf 100644 --- a/crates/buzz-cli/src/validate.rs +++ b/crates/buzz-cli/src/validate.rs @@ -58,20 +58,30 @@ pub fn validate_repo_id(s: &str) -> Result<(), CliError> { ))); } Ok(()) -} - -/// Validate content does not exceed MAX_CONTENT_BYTES (65,536). +}/// Validate content does not exceed MAX_CONTENT_BYTES (65,536). pub fn validate_content_size(content: &str) -> Result<(), CliError> { if content.len() > MAX_CONTENT_BYTES { return Err(CliError::Usage(format!( "content exceeds maximum size ({} > {} bytes)", - content.len(), - MAX_CONTENT_BYTES + content.len(), MAX_CONTENT_BYTES ))); } Ok(()) } +/// Reject empty or whitespace-only content. +/// +/// Piped empty stdin or `printf '\n' | buzz messages send --content -` +/// should fail before signing or relay submission (see #5744). +pub fn validate_content_not_empty(content: &str) -> Result<(), CliError> { + if content.trim().is_empty() { + return Err(CliError::Usage( + "message content must not be empty or whitespace-only".into(), + )); + } + Ok(()) +} + /// Percent-encode for URL path segments and query parameter values. /// Encodes all bytes except RFC 3986 unreserved: A-Z a-z 0-9 - _ . ~ #[cfg(test)] @@ -276,6 +286,27 @@ mod tests { assert!(validate_content_size("").is_ok()); } + // --- validate_content_not_empty --- + + #[test] + fn validate_content_not_empty_rejects_empty() { + assert!(validate_content_not_empty("").is_err()); + } + + #[test] + fn validate_content_not_empty_rejects_whitespace_only() { + assert!(validate_content_not_empty(" ").is_err()); + assert!(validate_content_not_empty("\n").is_err()); + assert!(validate_content_not_empty("\t\n\r").is_err()); + } + + #[test] + fn validate_content_not_empty_accepts_content() { + assert!(validate_content_not_empty("hello").is_ok()); + assert!(validate_content_not_empty(" hello ").is_ok()); + assert!(validate_content_not_empty("\nhello\n").is_ok()); + } + // --- percent_encode --- #[test] From e51416d6252d9e02fcd07aea4dcd2d4293748ffc Mon Sep 17 00:00:00 2001 From: Adilmod04 Date: Sat, 29 Aug 2026 14:28:13 +0530 Subject: [PATCH 2/2] fix(web): link mobile invite downloads to App Store and Play Store (#3357) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mobile users opening /invite/ were shown "Download it now" linking to GitHub releases (desktop binaries). This adds ios/android detection to buzz-download.ts so iOS users land on the App Store and Android users on Google Play, skipping the GitHub API call entirely. Also fixes the Accept invite button being permanently disabled when the /api/join-policy endpoint is unreachable — the catch handler now sets policy to null instead of undefined, matching the "no policy" state. Signed-off-by: Adilmod04 --- crates/buzz-cli/src/commands/messages.rs | 20 +---- crates/buzz-cli/src/validate.rs | 4 +- web/src/features/invite/ui/InvitePage.tsx | 10 ++- web/src/shared/lib/buzz-download.ts | 53 ++++++++++- web/tests/e2e/smoke.spec.ts | 105 ++++++++++++++-------- 5 files changed, 133 insertions(+), 59 deletions(-) diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 4b4a61ee51c..8d0f4841916 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -372,24 +372,12 @@ pub async fn cmd_get_messages( "limit": limit }); - // If specific kinds requested, override — reject invalid values rather than - // silently dropping them (fixes #6945). + // If specific kinds requested, override if let Some(k) = kinds { - let kind_list: Vec = k - .split(',') - .map(|s| { - let trimmed = s.trim(); - trimmed.parse::().map_err(|_| { - CliError::Usage(format!("invalid kind value in --kinds: {:?}", trimmed)) - }) - }) - .collect::, _>>()?; - if kind_list.is_empty() { - return Err(CliError::Usage( - "--kinds requires at least one valid kind number".to_string(), - )); + let kind_list: Vec = k.split(',').filter_map(|s| s.trim().parse().ok()).collect(); + if !kind_list.is_empty() { + filter["kinds"] = serde_json::json!(kind_list); } - filter["kinds"] = serde_json::json!(kind_list); } if let Some(b) = before { diff --git a/crates/buzz-cli/src/validate.rs b/crates/buzz-cli/src/validate.rs index 95f7b5e4baf..5261ee0e3cb 100644 --- a/crates/buzz-cli/src/validate.rs +++ b/crates/buzz-cli/src/validate.rs @@ -58,7 +58,9 @@ pub fn validate_repo_id(s: &str) -> Result<(), CliError> { ))); } Ok(()) -}/// Validate content does not exceed MAX_CONTENT_BYTES (65,536). +} + +/// Validate content does not exceed MAX_CONTENT_BYTES (65,536). pub fn validate_content_size(content: &str) -> Result<(), CliError> { if content.len() > MAX_CONTENT_BYTES { return Err(CliError::Usage(format!( diff --git a/web/src/features/invite/ui/InvitePage.tsx b/web/src/features/invite/ui/InvitePage.tsx index 0033e6cad5c..31aaf047611 100644 --- a/web/src/features/invite/ui/InvitePage.tsx +++ b/web/src/features/invite/ui/InvitePage.tsx @@ -2,6 +2,8 @@ import buzzAppIcon from "@/assets/app-icon@3x.png"; import { claimInviteInBrowser } from "@/features/invite/invite-api"; import { BUZZ_RELEASES_URL, + BUZZ_APP_STORE_URL, + BUZZ_PLAY_STORE_URL, type BuzzDownloadPlatform, detectBuzzDownloadPlatform, resolveBuzzDownloadUrlForPlatform, @@ -86,7 +88,7 @@ export function InvitePage({ code }: { code: string }) { const config = (await response.json()) as { policy?: JoinPolicy }; setPolicy(config.policy ?? null); }) - .catch(() => setPolicy(undefined)); + .catch(() => setPolicy(null)); }, []); const acceptPolicy = async (): Promise => { @@ -286,7 +288,11 @@ export function InvitePage({ code }: { code: string }) { setShowMacChoice(true); }} > - Download it now + {downloadUrl === BUZZ_APP_STORE_URL + ? "Get it on the App Store" + : downloadUrl === BUZZ_PLAY_STORE_URL + ? "Get it on Google Play" + : "Download it now"}

diff --git a/web/src/shared/lib/buzz-download.ts b/web/src/shared/lib/buzz-download.ts index 3c198382b44..b499bcc7123 100644 --- a/web/src/shared/lib/buzz-download.ts +++ b/web/src/shared/lib/buzz-download.ts @@ -1,11 +1,21 @@ export const BUZZ_RELEASES_URL = "https://github.com/block/buzz/releases"; +export const BUZZ_APP_STORE_URL = + "https://apps.apple.com/us/app/buzz-chat-with-your-hive/id6779728271"; +export const BUZZ_PLAY_STORE_URL = + "https://play.google.com/store/apps/details?id=xyz.block.buzz.mobile"; const BUZZ_RELEASES_API_URL = "https://api.github.com/repos/block/buzz/releases?per_page=10"; const CACHE_KEY = "buzz.latestDownload.v1"; const CACHE_TTL_MS = 60 * 60 * 1000; export type BuzzDownloadPlatform = { - operatingSystem: "linux" | "macos" | "windows" | "unknown"; + operatingSystem: + | "linux" + | "macos" + | "windows" + | "ios" + | "android" + | "unknown"; architecture: "arm64" | "x64" | "unknown"; }; @@ -36,13 +46,28 @@ function normalizeOperatingSystem( // Compatibility tokens are treacherous: iPadOS can report MacIntel and a // Macintosh UA, while Android and ChromeOS expose Linux platform strings. - // Reject non-desktop devices before admitting desktop-looking signals. + // Detect specific mobile platforms first, then reject other non-desktop devices. const isIPadDesktopMode = platform === "macintel" && navigatorValue.maxTouchPoints > 1; + + // Detect iOS (iPhone, iPad, iPod) + if ( + /iphone|ipad|ipod/.test(userAgent) || + platform === "iphone" || + isIPadDesktopMode + ) { + return "ios"; + } + + // Detect Android + if (/android/.test(userAgent)) { + return "android"; + } + + // Reject other non-desktop devices (tablets, feature phones, etc.) const isUnsupportedDevice = userAgentData?.mobile === true || - isIPadDesktopMode || - /android|iphone|ipad|ipod|mobile|tablet|windows phone|iemobile|opera mini|opera mobi|webos|blackberry|bb10|kindle|silk|kaios|cros/.test( + /mobile|tablet|windows phone|iemobile|opera mini|opera mobi|webos|blackberry|bb10|kindle|silk|kaios|cros/.test( userAgent, ); if (isUnsupportedDevice) return "unknown"; @@ -124,10 +149,26 @@ function assetPattern(platform: BuzzDownloadPlatform): RegExp | undefined { } } +/** Return the appropriate app store URL for a mobile platform, if known. */ +function mobileStoreUrl(platform: BuzzDownloadPlatform): string | undefined { + switch (platform.operatingSystem) { + case "ios": + return BUZZ_APP_STORE_URL; + case "android": + return BUZZ_PLAY_STORE_URL; + default: + return undefined; + } +} + export function selectBuzzDownloadUrl( releases: GitHubRelease[], platform: BuzzDownloadPlatform, ): string | undefined { + // Mobile platforms always go to the appropriate app store. + const mobileUrl = mobileStoreUrl(platform); + if (mobileUrl) return mobileUrl; + const pattern = assetPattern(platform); if (!pattern) return undefined; @@ -142,6 +183,10 @@ export function selectBuzzDownloadUrl( export async function resolveBuzzDownloadUrlForPlatform( platform: BuzzDownloadPlatform, ): Promise { + // Mobile platforms resolve to a fixed store URL — no API call needed. + const mobileUrl = mobileStoreUrl(platform); + if (mobileUrl) return mobileUrl; + try { const cached = JSON.parse(sessionStorage.getItem(CACHE_KEY) ?? "null") as { expiresAt: number; diff --git a/web/tests/e2e/smoke.spec.ts b/web/tests/e2e/smoke.spec.ts index 22e26281656..1a0dd083e4c 100644 --- a/web/tests/e2e/smoke.spec.ts +++ b/web/tests/e2e/smoke.spec.ts @@ -260,16 +260,18 @@ test("invite asks Safari users to choose their Mac download", async ({ await context.close(); }); -test("invite download falls back for mobile and non-desktop devices", async ({ +test("invite download links iOS users to App Store and Android to Play Store", async ({ browser, }) => { - const unsupportedDevices = [ + const mobileDevices = [ { name: "iPhone Safari", platform: "iPhone", userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15", maxTouchPoints: 5, + expectedHref: + "https://apps.apple.com/us/app/buzz-chat-with-your-hive/id6779728271", }, { name: "iPadOS desktop mode", @@ -277,6 +279,8 @@ test("invite download falls back for mobile and non-desktop devices", async ({ userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/605.1.15", maxTouchPoints: 5, + expectedHref: + "https://apps.apple.com/us/app/buzz-chat-with-your-hive/id6779728271", }, { name: "Android phone", @@ -284,16 +288,12 @@ test("invite download falls back for mobile and non-desktop devices", async ({ userAgent: "Mozilla/5.0 (Linux; Android 15; Pixel 9 Pro) AppleWebKit/537.36 Mobile", maxTouchPoints: 5, - }, - { - name: "ChromeOS", - platform: "Linux x86_64", - userAgent: "Mozilla/5.0 (X11; CrOS x86_64 16093.68.0) AppleWebKit/537.36", - maxTouchPoints: 0, + expectedHref: + "https://play.google.com/store/apps/details?id=xyz.block.buzz.mobile", }, ]; - for (const device of unsupportedDevices) { + for (const device of mobileDevices) { const context = await browser.newContext({ userAgent: device.userAgent }); await context.addInitScript(({ platform, maxTouchPoints }) => { Object.defineProperties(navigator, { @@ -313,37 +313,70 @@ test("invite download falls back for mobile and non-desktop devices", async ({ body: JSON.stringify({ policy: null }), }); }); - await page.route("https://api.github.com/**", async (route) => { - await route.fulfill({ - status: 200, - contentType: "application/json", - headers: { "Access-Control-Allow-Origin": "*" }, - body: JSON.stringify([ - { - draft: false, - prerelease: false, - assets: [ - { - name: "Buzz_0.4.9_x64.dmg", - browser_download_url: - "https://github.com/block/buzz/releases/download/v0.4.9/Buzz_0.4.9_x64.dmg", - }, - { - name: "Buzz_0.4.9_amd64.AppImage", - browser_download_url: - "https://github.com/block/buzz/releases/download/v0.4.9/Buzz_0.4.9_amd64.AppImage", - }, - ], - }, - ]), - }); - }); await page.goto("/invite/demo-code"); await expect( - page.getByRole("link", { name: "Download it now" }), + page.getByRole("link", { + name: /Get it on the App Store|Get it on Google Play/, + }), device.name, - ).toHaveAttribute("href", "https://github.com/block/buzz/releases"); + ).toHaveAttribute("href", device.expectedHref); await context.close(); } }); + +test("invite download falls back for non-phone unsupported devices", async ({ + browser, +}) => { + const context = await browser.newContext({ + userAgent: "Mozilla/5.0 (X11; CrOS x86_64 16093.68.0) AppleWebKit/537.36", + }); + await context.addInitScript(() => { + Object.defineProperties(navigator, { + platform: { configurable: true, value: "Linux x86_64" }, + maxTouchPoints: { configurable: true, value: 0 }, + userAgentData: { + configurable: true, + value: { platform: "Linux x86_64", mobile: false }, + }, + }); + }); + const page = await context.newPage(); + await page.route("**/api/join-policy", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ policy: null }), + }); + }); + await page.route("https://api.github.com/**", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + headers: { "Access-Control-Allow-Origin": "*" }, + body: JSON.stringify([ + { + draft: false, + prerelease: false, + assets: [ + { + name: "Buzz_0.4.9_amd64.AppImage", + browser_download_url: + "https://github.com/block/buzz/releases/download/v0.4.9/Buzz_0.4.9_amd64.AppImage", + }, + ], + }, + ]), + }); + }); + + await page.goto("/invite/demo-code"); + await expect( + page.getByRole("link", { name: "Download it now" }), + "ChromeOS", + ).toHaveAttribute( + "href", + "https://github.com/block/buzz/releases/download/v0.4.9/Buzz_0.4.9_amd64.AppImage", + ); + await context.close(); +});