From 2cf8e530ffe6240e862b823e5ed006d669c096ab Mon Sep 17 00:00:00 2001 From: npub1qvn3cujt28pg06ehlstrxyz6ayzp06t4uc7r566vxwwgrv24hglq9zju0n <03271c724b51c287eb37fc1633105ae90417e975e63c3a6b4c339c81b155ba3e@sprout-oss.stage.blox.sqprod.co> Date: Thu, 16 Jul 2026 21:12:59 -0700 Subject: [PATCH 1/2] fix(desktop): invalidate stale media lookups Co-authored-by: npub1qvn3cujt28pg06ehlstrxyz6ayzp06t4uc7r566vxwwgrv24hglq9zju0n <03271c724b51c287eb37fc1633105ae90417e975e63c3a6b4c339c81b155ba3e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1qvn3cujt28pg06ehlstrxyz6ayzp06t4uc7r566vxwwgrv24hglq9zju0n <03271c724b51c287eb37fc1633105ae90417e975e63c3a6b4c339c81b155ba3e@sprout-oss.stage.blox.sqprod.co> --- desktop/src/shared/lib/mediaUrl.test.mjs | 53 ++++++++++++++++++++++++ desktop/src/shared/lib/mediaUrl.ts | 17 +++++++- 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/desktop/src/shared/lib/mediaUrl.test.mjs b/desktop/src/shared/lib/mediaUrl.test.mjs index e436f968505..c85c98f91cd 100644 --- a/desktop/src/shared/lib/mediaUrl.test.mjs +++ b/desktop/src/shared/lib/mediaUrl.test.mjs @@ -5,9 +5,62 @@ import { mediaProxyUrl } from "./mediaUrl.ts"; const HASH = "a".repeat(64); +function deferred() { + let resolve; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + test("mediaProxyUrl: uses the IPv4 loopback literal for the localhost proxy", () => { assert.equal( mediaProxyUrl(54321, `${HASH}.png`), `http://127.0.0.1:54321/media/${HASH}.png`, ); }); + +test("resetMediaCaches: ignores relay origin lookups from the previous generation", async () => { + const previousWindow = globalThis.window; + const staleOrigin = deferred(); + let relayOriginCalls = 0; + + globalThis.window = { + __TAURI_INTERNALS__: { + invoke(command) { + if (command === "get_media_proxy_port") return Promise.resolve(54321); + if (command === "get_relay_http_url") { + relayOriginCalls += 1; + return relayOriginCalls === 1 + ? staleOrigin.promise + : Promise.resolve("https://active.example"); + } + return Promise.reject(new Error(`Unexpected command: ${command}`)); + }, + }, + }; + + try { + // A unique URL triggers module-load fetching with the stale relay lookup + // still unresolved, matching a cold launch before applyCommunity finishes. + const mediaUrl = await import(`./mediaUrl.ts?race=${Date.now()}`); + await new Promise((resolve) => setTimeout(resolve, 0)); + + mediaUrl.resetMediaCaches(); + const activeUrl = `https://active.example/media/${HASH}.png`; + mediaUrl.rewriteRelayUrl(activeUrl); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Complete the old lookup after reset. It must not overwrite the active + // community origin fetched by the new generation. + staleOrigin.resolve("https://stale.example"); + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.equal( + mediaUrl.rewriteRelayUrl(activeUrl), + `http://127.0.0.1:54321/media/${HASH}.png`, + ); + } finally { + globalThis.window = previousWindow; + } +}); diff --git a/desktop/src/shared/lib/mediaUrl.ts b/desktop/src/shared/lib/mediaUrl.ts index 94e4cc41e55..fdfc854942c 100644 --- a/desktop/src/shared/lib/mediaUrl.ts +++ b/desktop/src/shared/lib/mediaUrl.ts @@ -29,6 +29,13 @@ let portPromise: Promise | null = null; /** Cached relay origin (e.g. "https://buzz-oss.stage.blox.sqprod.co"). */ let cachedRelayOrigin: string | null = null; +/** + * Monotonic cache generation. Async lookups capture the current generation and + * may only publish results while it is still current. This prevents a lookup + * started for the previous community from repopulating caches after reset. + */ +let cacheGeneration = 0; + const POLL_INTERVAL_MS = 100; const POLL_TIMEOUT_MS = 5000; @@ -38,20 +45,25 @@ const POLL_TIMEOUT_MS = 5000; * Returns the port, or null if the proxy never came up. */ async function fetchProxyPort(): Promise { + const generation = cacheGeneration; + // Fetch relay origin in parallel — fire-and-forget, no retry needed. if (!cachedRelayOrigin) { invoke("get_relay_http_url") .then((url) => { - cachedRelayOrigin = url.replace(/\/+$/, ""); + if (generation === cacheGeneration) { + cachedRelayOrigin = url.replace(/\/+$/, ""); + } }) .catch(() => {}); } const deadline = Date.now() + POLL_TIMEOUT_MS; - while (Date.now() < deadline) { + while (Date.now() < deadline && generation === cacheGeneration) { try { const port = await invoke("get_media_proxy_port"); if (port > 0) { + if (generation !== cacheGeneration) return null; cachedPort = port; return port; } @@ -75,6 +87,7 @@ if (typeof window !== "undefined") { * and relay origin for the new community. */ export function resetMediaCaches(): void { + cacheGeneration += 1; cachedPort = null; portPromise = null; cachedRelayOrigin = null; From 01397c5c8c64a2a4e8cbc18aa90c638458e9d876 Mon Sep 17 00:00:00 2001 From: npub1zurdm6fx3kksz8f8d8y4js3mvjyu3rshlpqejr8wa8hta5tsqqeshmes7w <1706dde9268dad011d2769c959423b6489c88e17f841990ceee9eebed1700033@sprout-oss.stage.blox.sqprod.co> Date: Thu, 16 Jul 2026 21:40:22 -0700 Subject: [PATCH 2/2] fix(desktop): case-insensitive relay media origin match, release 0.4.13 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The relay always emits media URLs with a lowercased tenant host (normalize_host in buzz-core), but the saved community relay URL keeps the user's typed casing (DNS is case-insensitive, so e.g. wss://PENDING-SEED.communities.buzz.xyz connects fine). rewriteRelayUrl compared the two with a raw startsWith, so the relay's own media URLs were misclassified as external Blossom URLs and loaded without the authenticated localhost proxy — with BUZZ_REQUIRE_MEDIA_GET_AUTH=true the GET is rejected and previews render as broken placeholders. The 0.4.12 cache reset could not fix this because the refetched origin has the same casing. Compare canonicalized origins (new URL().origin lowercases scheme/host and drops default ports) on both sides instead. Regression test fails against the previous comparison and passes with the fix; external Blossom URLs still pass through unchanged. Bumps Desktop to 0.4.13 so merge auto-tags v0.4.13 and triggers the release workflow. Co-authored-by: npub1zurdm6fx3kksz8f8d8y4js3mvjyu3rshlpqejr8wa8hta5tsqqeshmes7w <1706dde9268dad011d2769c959423b6489c88e17f841990ceee9eebed1700033@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1zurdm6fx3kksz8f8d8y4js3mvjyu3rshlpqejr8wa8hta5tsqqeshmes7w <1706dde9268dad011d2769c959423b6489c88e17f841990ceee9eebed1700033@sprout-oss.stage.blox.sqprod.co> --- CHANGELOG.md | 5 +++ desktop/package.json | 2 +- desktop/src-tauri/Cargo.lock | 2 +- desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/tauri.conf.json | 2 +- desktop/src/shared/lib/mediaUrl.test.mjs | 57 ++++++++++++++++++++++++ desktop/src/shared/lib/mediaUrl.ts | 37 +++++++++++++-- 7 files changed, 99 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8c7d0033b9..ae835ea9dd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## v0.4.13 + +- fix(desktop): compare relay media origins case-insensitively so uploads render when the saved community URL has uppercase characters +- fix(desktop): invalidate stale media lookups so an in-flight relay-origin fetch cannot repopulate caches after a community reset + ## v0.4.12 - fix(desktop): proxy authenticated relay media after cold-start community initialization diff --git a/desktop/package.json b/desktop/package.json index 6ea9fdfe965..f07b282d4f1 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "buzz", "private": true, - "version": "0.4.12", + "version": "0.4.13", "type": "module", "scripts": { "dev": "vite", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 8b9b413e1b7..2db15a9b547 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -972,7 +972,7 @@ dependencies = [ [[package]] name = "buzz-desktop" -version = "0.4.12" +version = "0.4.13" dependencies = [ "anyhow", "arboard", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 3a2738271db..68005f41d34 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "buzz-desktop" -version = "0.4.12" +version = "0.4.13" description = "Buzz desktop app" authors = ["you"] edition = "2021" diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 7cf708d14b6..0e611556ed2 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Buzz", - "version": "0.4.12", + "version": "0.4.13", "identifier": "xyz.block.buzz.app", "build": { "beforeDevCommand": { diff --git a/desktop/src/shared/lib/mediaUrl.test.mjs b/desktop/src/shared/lib/mediaUrl.test.mjs index c85c98f91cd..f087d3db195 100644 --- a/desktop/src/shared/lib/mediaUrl.test.mjs +++ b/desktop/src/shared/lib/mediaUrl.test.mjs @@ -64,3 +64,60 @@ test("resetMediaCaches: ignores relay origin lookups from the previous generatio globalThis.window = previousWindow; } }); + +test("rewriteRelayUrl: matches relay origin case-insensitively (uppercase saved community URL)", async () => { + const previousWindow = globalThis.window; + + globalThis.window = { + __TAURI_INTERNALS__: { + invoke(command) { + if (command === "get_media_proxy_port") return Promise.resolve(54321); + if (command === "get_relay_http_url") { + // Saved community URLs keep the user's casing; the relay always + // emits lowercased media URLs (normalize_host in buzz-core). + return Promise.resolve("https://PENDING-SEED.communities.buzz.xyz"); + } + return Promise.reject(new Error(`Unexpected command: ${command}`)); + }, + }, + }; + + try { + const mediaUrl = await import(`./mediaUrl.ts?case=${Date.now()}`); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const relayMediaUrl = `https://pending-seed.communities.buzz.xyz/media/${HASH}.png`; + assert.equal( + mediaUrl.rewriteRelayUrl(relayMediaUrl), + `http://127.0.0.1:54321/media/${HASH}.png`, + ); + } finally { + globalThis.window = previousWindow; + } +}); + +test("rewriteRelayUrl: still passes external Blossom URLs through unchanged", async () => { + const previousWindow = globalThis.window; + + globalThis.window = { + __TAURI_INTERNALS__: { + invoke(command) { + if (command === "get_media_proxy_port") return Promise.resolve(54321); + if (command === "get_relay_http_url") { + return Promise.resolve("https://relay.example"); + } + return Promise.reject(new Error(`Unexpected command: ${command}`)); + }, + }, + }; + + try { + const mediaUrl = await import(`./mediaUrl.ts?external=${Date.now()}`); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const externalUrl = `https://nostr.build/media/${HASH}.png`; + assert.equal(mediaUrl.rewriteRelayUrl(externalUrl), externalUrl); + } finally { + globalThis.window = previousWindow; + } +}); diff --git a/desktop/src/shared/lib/mediaUrl.ts b/desktop/src/shared/lib/mediaUrl.ts index fdfc854942c..075575a314b 100644 --- a/desktop/src/shared/lib/mediaUrl.ts +++ b/desktop/src/shared/lib/mediaUrl.ts @@ -26,9 +26,32 @@ const RELAY_MEDIA_RE = let cachedPort: number | null = null; let portPromise: Promise | null = null; -/** Cached relay origin (e.g. "https://buzz-oss.stage.blox.sqprod.co"). */ +/** + * Cached relay origin (e.g. "https://buzz-oss.stage.blox.sqprod.co"), + * canonicalized via {@link canonicalOrigin} so comparisons are stable. + */ let cachedRelayOrigin: string | null = null; +/** + * Canonicalize a URL to its origin with a lowercased scheme/host. + * + * The relay always emits media URLs with a lowercased tenant host + * (`normalize_host` in buzz-core), but the saved community relay URL keeps + * whatever casing the user typed (DNS is case-insensitive, so an uppercase + * host connects fine). A raw string comparison between the two misclassifies + * the relay's own media URLs as external and skips the authenticated proxy. + * `new URL().origin` lowercases scheme + host and drops default ports. + * + * Returns null for unparseable input. + */ +function canonicalOrigin(url: string): string | null { + try { + return new URL(url).origin; + } catch { + return null; + } +} + /** * Monotonic cache generation. Async lookups capture the current generation and * may only publish results while it is still current. This prevents a lookup @@ -52,7 +75,7 @@ async function fetchProxyPort(): Promise { invoke("get_relay_http_url") .then((url) => { if (generation === cacheGeneration) { - cachedRelayOrigin = url.replace(/\/+$/, ""); + cachedRelayOrigin = canonicalOrigin(url); } }) .catch(() => {}); @@ -117,8 +140,14 @@ export function rewriteRelayUrl(url: string): string { // (different origin) pass through unchanged — they work fine via WKWebView. // If the relay origin isn't cached yet, fall through to the rewrite path // as a safe default (relay URLs need the proxy to avoid Cloudflare 403s). - if (cachedRelayOrigin && !url.startsWith(`${cachedRelayOrigin}/`)) { - return url; + // Compare canonicalized origins: hosts are case-insensitive, and the relay + // always returns lowercased media URLs even when the saved community URL + // was typed with uppercase (e.g. wss://PENDING-SEED.communities.buzz.xyz). + if (cachedRelayOrigin) { + const urlOrigin = canonicalOrigin(url); + if (urlOrigin !== cachedRelayOrigin) { + return url; + } } if (cachedPort && cachedPort > 0) {