diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 7d06c4da91b..a28c10d0bf3 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -65,6 +65,7 @@ export default defineConfig({ "**/spoiler.spec.ts", "**/composer-link-shortcut.spec.ts", "**/entity-link-recipient-cards.spec.ts", + "**/release-run-preview.spec.ts", "**/composer-selection-formatting.spec.ts", "**/composer-tooltip-dismiss.spec.ts", "**/mentions.spec.ts", diff --git a/desktop/src/shared/lib/releaseRunLink.test.mjs b/desktop/src/shared/lib/releaseRunLink.test.mjs new file mode 100644 index 00000000000..da8ca160595 --- /dev/null +++ b/desktop/src/shared/lib/releaseRunLink.test.mjs @@ -0,0 +1,90 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildReleaseRunLink, + parseReleaseRunLink, + releaseRunViewState, +} from "./releaseRunLink.ts"; + +const payload = { + version: 1, + runId: "deezer-2026-08-22T06:00:33.651Z", + runName: "Deezer release re-identification", + status: "completed", + checked: 40, + released: 3, + held: 37, + sourceHealth: "Deezer healthy", + finishedAt: "2026-08-22T06:00:33.651Z", + tracks: [ + { + id: "track-1", + artist: "D Stone", + title: "Total Unison", + version: "Original Mix", + label: "Heist Recordings", + releaseDate: "2026-08-22", + artworkUrl: "https://cdn.example.com/total-unison.jpg", + source: "Deezer", + sourceUrl: "https://www.deezer.com/track/123", + detailsUrl: "https://team.trakthat.app/releases?track=track-1", + }, + { + id: "track-2", + artist: "Fleur Shore", + title: "Higher", + label: "Cuttin' Headz", + releaseDate: "2026-08-22", + source: "Deezer", + }, + { + id: "track-3", + artist: "M-High", + title: "The Answer", + releaseDate: "2026-08-22", + source: "Deezer", + }, + ], +}; + +test("release run link round-trips a complete bounded payload", () => { + const href = buildReleaseRunLink(payload); + assert.match(href, /^buzz:\/\/release-run\?data=[A-Za-z0-9_-]+$/); + assert.deepEqual(parseReleaseRunLink(href), payload); + assert.equal(releaseRunViewState(payload), "ready"); +}); + +test("release run parser rejects a mismatched released count", () => { + const href = buildReleaseRunLink({ ...payload, released: 2 }); + assert.equal(parseReleaseRunLink(href), null); +}); + +test("release run parser rejects insecure artwork and destinations", () => { + const href = buildReleaseRunLink({ + ...payload, + tracks: [ + { ...payload.tracks[0], artworkUrl: "http://cdn.example.com/cover.jpg" }, + ...payload.tracks.slice(1), + ], + }); + assert.equal(parseReleaseRunLink(href), null); +}); + +test("release run parser rejects duplicate and unknown query parameters", () => { + const href = buildReleaseRunLink(payload); + const data = new URL(href).searchParams.get("data"); + assert.equal( + parseReleaseRunLink(`${href}&data=${data}`), + null, + "duplicate data parameter", + ); + assert.equal(parseReleaseRunLink(`${href}&open=external`), null); +}); + +test("release run view state distinguishes live, empty, and failed runs", () => { + const empty = { ...payload, released: 0, tracks: [] }; + assert.equal(releaseRunViewState({ ...empty, status: "running" }), "loading"); + assert.equal(releaseRunViewState({ ...empty, status: "completed" }), "empty"); + assert.equal(releaseRunViewState({ ...empty, status: "failed" }), "failed"); +}); diff --git a/desktop/src/shared/lib/releaseRunLink.ts b/desktop/src/shared/lib/releaseRunLink.ts new file mode 100644 index 00000000000..c45feeb8991 --- /dev/null +++ b/desktop/src/shared/lib/releaseRunLink.ts @@ -0,0 +1,246 @@ +export type ReleaseRunTrack = { + id: string; + artist: string; + title: string; + version?: string; + label?: string; + releaseDate: string; + artworkUrl?: string; + source: string; + sourceUrl?: string; + detailsUrl?: string; +}; + +export type ReleaseRunPayload = { + version: 1; + runId: string; + runName: string; + status: string; + checked: number; + released: number; + held: number; + sourceHealth: string; + finishedAt: string; + tracks: ReleaseRunTrack[]; +}; + +export type ReleaseRunViewState = "loading" | "empty" | "failed" | "ready"; + +const MAX_ENCODED_PAYLOAD_LENGTH = 48_000; +const MAX_TRACKS = 50; +const RUN_ID_PATTERN = /^[A-Za-z0-9._:-]{1,120}$/; +const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function boundedString(value: unknown, maximumLength: number): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + if (!trimmed || trimmed.length > maximumLength) return null; + return trimmed; +} + +function optionalBoundedString( + value: unknown, + maximumLength: number, +): string | undefined | null { + if (value == null || value === "") return undefined; + return boundedString(value, maximumLength); +} + +function count(value: unknown): number | null { + return typeof value === "number" && + Number.isInteger(value) && + value >= 0 && + value <= 1_000_000 + ? value + : null; +} + +function httpsUrl(value: unknown): string | undefined | null { + const candidate = optionalBoundedString(value, 2_000); + if (candidate == null) return candidate; + try { + const parsed = new URL(candidate); + return parsed.protocol === "https:" && + !parsed.username && + !parsed.password && + parsed.hostname + ? parsed.href + : null; + } catch { + return null; + } +} + +function decodeBase64Url(value: string): string | null { + if ( + !value || + value.length > MAX_ENCODED_PAYLOAD_LENGTH || + !BASE64URL_PATTERN.test(value) + ) { + return null; + } + + try { + const padded = value.replace(/-/g, "+").replace(/_/g, "/"); + const padding = "=".repeat((4 - (padded.length % 4)) % 4); + const binary = atob(`${padded}${padding}`); + const bytes = Uint8Array.from(binary, (character) => + character.charCodeAt(0), + ); + return new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + return null; + } +} + +function parseTrack(value: unknown): ReleaseRunTrack | null { + if (!isRecord(value)) return null; + + const id = boundedString(value.id, 160); + const artist = boundedString(value.artist, 200); + const title = boundedString(value.title, 240); + const version = optionalBoundedString(value.version, 160); + const label = optionalBoundedString(value.label, 200); + const releaseDate = boundedString(value.releaseDate, 40); + const artworkUrl = httpsUrl(value.artworkUrl); + const source = boundedString(value.source, 100); + const sourceUrl = httpsUrl(value.sourceUrl); + const detailsUrl = httpsUrl(value.detailsUrl); + + if ( + !id || + !artist || + !title || + !releaseDate || + !source || + version === null || + label === null || + artworkUrl === null || + sourceUrl === null || + detailsUrl === null + ) { + return null; + } + + return { + id, + artist, + title, + ...(version ? { version } : {}), + ...(label ? { label } : {}), + releaseDate, + ...(artworkUrl ? { artworkUrl } : {}), + source, + ...(sourceUrl ? { sourceUrl } : {}), + ...(detailsUrl ? { detailsUrl } : {}), + }; +} + +export function parseReleaseRunLink(href: string): ReleaseRunPayload | null { + let parsed: URL; + try { + parsed = new URL(href); + } catch { + return null; + } + + if ( + parsed.protocol !== "buzz:" || + parsed.hostname !== "release-run" || + (parsed.pathname !== "" && parsed.pathname !== "/") || + parsed.hash || + parsed.username || + parsed.password || + parsed.port + ) { + return null; + } + + if ( + [...parsed.searchParams.keys()].some((key) => key !== "data") || + parsed.searchParams.getAll("data").length !== 1 + ) { + return null; + } + + const decoded = decodeBase64Url(parsed.searchParams.get("data") ?? ""); + if (!decoded) return null; + + let raw: unknown; + try { + raw = JSON.parse(decoded); + } catch { + return null; + } + if (!isRecord(raw) || raw.version !== 1) return null; + + const runId = boundedString(raw.runId, 120); + const runName = boundedString(raw.runName, 200); + const status = boundedString(raw.status, 80); + const checked = count(raw.checked); + const released = count(raw.released); + const held = count(raw.held); + const sourceHealth = boundedString(raw.sourceHealth, 500); + const finishedAt = boundedString(raw.finishedAt, 80); + + if ( + !runId || + !RUN_ID_PATTERN.test(runId) || + !runName || + !status || + checked === null || + released === null || + held === null || + !sourceHealth || + !finishedAt || + Number.isNaN(Date.parse(finishedAt)) || + !Array.isArray(raw.tracks) || + raw.tracks.length > MAX_TRACKS + ) { + return null; + } + + const tracks = raw.tracks.map(parseTrack); + if (tracks.some((track) => track === null)) return null; + if (released !== tracks.length) return null; + + return { + version: 1, + runId, + runName, + status, + checked, + released, + held, + sourceHealth, + finishedAt, + tracks: tracks as ReleaseRunTrack[], + }; +} + +export function releaseRunViewState( + payload: ReleaseRunPayload, +): ReleaseRunViewState { + const status = payload.status.toLowerCase(); + if (/running|pending|queued|started/.test(status)) return "loading"; + if (/failed|error|critical/.test(status) && payload.tracks.length === 0) { + return "failed"; + } + return payload.tracks.length === 0 ? "empty" : "ready"; +} + +export function buildReleaseRunLink(payload: ReleaseRunPayload): string { + const json = JSON.stringify(payload); + const bytes = new TextEncoder().encode(json); + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + const data = btoa(binary) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); + return `buzz://release-run?data=${data}`; +} diff --git a/desktop/src/shared/styles/globals/components.css b/desktop/src/shared/styles/globals/components.css index f206539a937..ba480f40dc6 100644 --- a/desktop/src/shared/styles/globals/components.css +++ b/desktop/src/shared/styles/globals/components.css @@ -784,3 +784,36 @@ } } } + +@layer components { + .buzz-release-run-surface { + background: hsl(var(--background) / 0.82); + border: 1px solid hsl(var(--border) / 0.58); + box-shadow: + inset 0 1px 0 rgb(255 255 255 / 0.22), + inset 0 -1px 0 rgb(0 0 0 / 0.08), + 0 28px 72px -36px rgb(0 0 0 / 0.58), + 0 10px 28px -20px rgb(0 0 0 / 0.28); + -webkit-backdrop-filter: blur(28px) saturate(150%); + backdrop-filter: blur(28px) saturate(150%); + } + + .dark .buzz-release-run-surface { + background: hsl(var(--background) / 0.72); + border-color: rgb(255 255 255 / 0.16); + box-shadow: + inset 0 1px 0 rgb(255 255 255 / 0.14), + inset 0 -1px 0 rgb(0 0 0 / 0.14), + 0 30px 76px -34px rgb(0 0 0 / 0.78), + 0 12px 30px -20px rgb(0 0 0 / 0.54); + } + + @media (prefers-reduced-transparency: reduce), (prefers-contrast: more) { + .buzz-release-run-surface, + .dark .buzz-release-run-surface { + background: hsl(var(--background)); + -webkit-backdrop-filter: none; + backdrop-filter: none; + } + } +} diff --git a/desktop/src/shared/ui/markdown.test.mjs b/desktop/src/shared/ui/markdown.test.mjs index 5e829f62434..6d6cbd68eb6 100644 --- a/desktop/src/shared/ui/markdown.test.mjs +++ b/desktop/src/shared/ui/markdown.test.mjs @@ -537,6 +537,10 @@ import ReactMarkdown, { defaultUrlTransform } from "react-markdown"; import { isChannelLink } from "../../features/messages/lib/channelLink.ts"; import { isMessageLink } from "../../features/messages/lib/messageLink.ts"; import { parseEntityLink } from "../lib/entityLink.ts"; +import { + buildReleaseRunLink, + parseReleaseRunLink, +} from "../lib/releaseRunLink.ts"; import remarkSpoilers from "../lib/remarkSpoilers.ts"; const OWNER_HEX = @@ -548,6 +552,7 @@ function buzzDeepLinkUrlTransform(value, key) { if (key !== "href") return defaultUrlTransform(value); if (isMessageLink(value) || isChannelLink(value)) return value; if (parseEntityLink(value).ok) return value; + if (parseReleaseRunLink(value)) return value; return defaultUrlTransform(value); } @@ -627,6 +632,43 @@ test("messageLinkUrlTransform: leaves non-entity buzz:// schemes to default", () assert.match(html, /href=""/); }); +test("buzzDeepLinkUrlTransform: preserves a valid release-run payload", () => { + const releaseRunLink = buildReleaseRunLink({ + version: 1, + runId: "deezer-2026-08-22", + runName: "Deezer release re-identification", + status: "completed", + checked: 40, + released: 1, + held: 39, + sourceHealth: "Deezer healthy", + finishedAt: "2026-08-22T06:00:33.651Z", + tracks: [ + { + id: "track-1", + artist: "D Stone", + title: "Total Unison", + version: "Original Mix", + label: "Heist Recordings", + releaseDate: "2026-08-22", + artworkUrl: "https://cdn.example.com/total-unison.jpg", + source: "Deezer", + detailsUrl: "https://team.trakthat.app/releases?track=track-1", + }, + ], + }); + const html = renderMarkdown(`[Open 1 release](${releaseRunLink})`); + assert.match(html, /href="buzz:\/\/release-run\?data=/); + assert.doesNotMatch(html, /href=""/); +}); + +test("buzzDeepLinkUrlTransform: strips a malformed release-run payload", () => { + const html = renderMarkdown( + "[Open releases](buzz://release-run?data=not-valid-json)", + ); + assert.match(html, /href=""/); +}); + test("buzzDeepLinkUrlTransform: preserves buzz://pr entity link href", () => { const prLink = `buzz://pr?id=${EVENT_HEX}&owner=${OWNER_HEX}&d=buzz-world`; const html = renderMarkdown(`[My PR](${prLink})`); diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index a556278eade..ad034055088 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -25,6 +25,7 @@ import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext" import { cn } from "@/shared/lib/cn"; import { parseEntityLink } from "@/shared/lib/entityLink"; import { parseSupportedLinkPreview } from "@/shared/lib/linkPreview"; +import { parseReleaseRunLink } from "@/shared/lib/releaseRunLink"; import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; import { useRelayOrigin } from "@/shared/lib/useRelayOrigin"; import { AttachmentGroup } from "@/shared/ui/attachment"; @@ -120,6 +121,7 @@ import { MarkdownTable } from "./markdown/MarkdownTable"; import { ProgressiveImage } from "./markdown/ProgressiveImage"; import { BuzzInlineLink } from "./markdown/BuzzLinkChip"; import { MessageLinkPill } from "./markdown/MessageLinkPill"; +import { ReleaseRunLink } from "./markdown/ReleaseRunLink"; import { renderCachedMarkdown } from "./markdown/nodeCache"; import { useMessageLinkPreviews } from "./markdown/useMessageLinkPreviews"; import { @@ -1291,8 +1293,7 @@ export function createMarkdownComponents( const label = getReactNodeText(children); - // Snapshot attachment (agent or team): classify before generic FileCard. - // resolveSnapshotCard checks the filename suffix + SHA-256 field. + // Classify snapshots before generic files using the filename + SHA-256. const snapshotCard = resolveSnapshotCard( href ? imetaByUrl?.get(href) : undefined, href, @@ -1320,9 +1321,7 @@ export function createMarkdownComponents( ); } - // Generic file attachment: a `[filename](url)` link whose href matches an - // imeta entry with a non-image, non-video MIME. Render a download card - // instead of a plain link. (Media uses the `img` renderer, not this path.) + // Non-media `[filename](url)` imeta entries render as download cards. const card = resolveFileCard( href ? imetaByUrl?.get(href) : undefined, href, @@ -1334,9 +1333,11 @@ export function createMarkdownComponents( ); } - // Intercept `buzz://channel/` and `buzz://message?...` links so - // clicks navigate in-app instead of opening the URL in the OS browser. if (href) { + const releaseRun = parseReleaseRunLink(href); + if (releaseRun) { + return {children}; + } if (parseChannelLink(href).ok) { return ( + {track.artworkUrl ? ( +