Skip to content
Closed
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
8 changes: 8 additions & 0 deletions desktop/src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@
"core:window:allow-close",
"notification:default",
"opener:default",
{
"identifier": "opener:allow-open-url",
"allow": [
{ "url": "obsidian://open[?]file=*" },
{ "url": "obsidian://open[?]path=*" },
{ "url": "obsidian://open[?]vault=*" }
]
},
"websocket:default",
"window-state:default",
"dialog:default",
Expand Down
43 changes: 43 additions & 0 deletions desktop/src/shared/lib/obsidianLink.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import assert from "node:assert/strict";
import test from "node:test";

import { isObsidianOpenLink } from "./obsidianLink.ts";

test("accepts Obsidian open links for vault-relative and absolute targets", () => {
assert.equal(
isObsidianOpenLink(
"obsidian://open?vault=holistics-digest&file=agent-cowork%2Fprojects%2Fanfra%2FANFRA_SETUP_END_TO_END.canvas",
),
true,
);
assert.equal(isObsidianOpenLink("obsidian://open?file=README.md"), true);
assert.equal(
isObsidianOpenLink("obsidian://open?path=%2Ftmp%2Fexample.md"),
true,
);
});

test("rejects non-open actions and ambiguous or extensible parameters", () => {
for (const link of [
"obsidian://new?vault=work&name=note",
"obsidian://search?vault=work&query=secret",
"obsidian://open?vault=work&file=note.md&x-success=https://example.com",
"obsidian://open?vault=first&vault=second",
"obsidian://open?vault=work&path=%2Ftmp%2Fnote.md",
]) {
assert.equal(isObsidianOpenLink(link), false, link);
}
});

test("rejects malformed or unsafe open links", () => {
for (const link of [
"obsidian://open",
"obsidian://user@open?vault=work",
"obsidian://open/extra?vault=work",
"obsidian://open?vault=work#fragment",
"obsidian://open?vault=%00work",
"not a URL",
]) {
assert.equal(isObsidianOpenLink(link), false, link);
}
});
53 changes: 53 additions & 0 deletions desktop/src/shared/lib/obsidianLink.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
const OBSIDIAN_OPEN_PARAMS = new Set(["file", "path", "vault"]);
const MAX_OBSIDIAN_LINK_LENGTH = 8_192;

function hasControlCharacters(value: string): boolean {
return Array.from(value).some((character) => {
const codePoint = character.codePointAt(0) ?? 0;
return codePoint <= 0x1f || codePoint === 0x7f;
});
}

/**
* Accept only Obsidian's read-oriented `open` action. Other Obsidian URI
* actions can create notes, run searches, or invoke plugin-defined behavior,
* so message links must never pass arbitrary `obsidian://` URLs to the OS.
*/
export function isObsidianOpenLink(value: string): boolean {
if (!value || value.length > MAX_OBSIDIAN_LINK_LENGTH) return false;

let url: URL;
try {
url = new URL(value);
} catch {
return false;
}

if (
url.protocol !== "obsidian:" ||
url.hostname !== "open" ||
(url.pathname !== "" && url.pathname !== "/") ||
url.username ||
url.password ||
url.port ||
url.hash
) {
return false;
}

for (const key of url.searchParams.keys()) {
if (!OBSIDIAN_OPEN_PARAMS.has(key)) return false;
}
for (const key of OBSIDIAN_OPEN_PARAMS) {
if (url.searchParams.getAll(key).length > 1) return false;
}

const vault = url.searchParams.get("vault")?.trim() ?? "";
const file = url.searchParams.get("file")?.trim() ?? "";
const path = url.searchParams.get("path")?.trim() ?? "";
if ([vault, file, path].some(hasControlCharacters)) return false;

// `path` is the absolute-path form. `vault`/`file` address a vault-relative
// target and may be used individually or together.
return path ? !vault && !file : Boolean(vault || file);
}
18 changes: 18 additions & 0 deletions desktop/src/shared/ui/markdown.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,7 @@ 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 { isObsidianOpenLink } from "../lib/obsidianLink.ts";
import remarkSpoilers from "../lib/remarkSpoilers.ts";

const OWNER_HEX =
Expand All @@ -549,6 +550,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 (isObsidianOpenLink(value)) return value;
return defaultUrlTransform(value);
}

Expand Down Expand Up @@ -611,6 +613,22 @@ test("messageLinkUrlTransform: passes http(s) through unchanged", () => {
assert.match(html, /href="https:\/\/example\.com\/path"/);
});

test("buzzDeepLinkUrlTransform: preserves validated Obsidian open links", () => {
const link =
"obsidian://open?vault=holistics-digest&file=agent-cowork%2Fprojects%2Fanfra%2FANFRA_SETUP_END_TO_END.canvas";
const html = renderMarkdown(`[Open in Obsidian](${link})`);
assert.match(html, /href="obsidian:\/\/open\?/);
assert.doesNotMatch(html, /href=""/);
});

test("buzzDeepLinkUrlTransform: strips other Obsidian actions", () => {
const html = renderMarkdown(
"[Create note](obsidian://new?vault=holistics-digest&name=unsafe)",
);
assert.match(html, /href=""/);
assert.doesNotMatch(html, /obsidian:\/\/new/);
});

test("messageLinkUrlTransform: preserves legacy buzz://message href", () => {
const html = renderMarkdown(
"Click [here](buzz://message?channel=abc&id=xyz)",
Expand Down
19 changes: 16 additions & 3 deletions desktop/src/shared/ui/markdown/ExternalLinkAnchor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { toast } from "sonner";

import { cn } from "@/shared/lib/cn";
import { copyTextToClipboard } from "@/shared/lib/clipboard";
import { isObsidianOpenLink } from "@/shared/lib/obsidianLink";

import { MaskedLinkTooltip } from "./MaskedLinkTooltip";
import {
Expand Down Expand Up @@ -37,6 +38,12 @@ export function ExternalLinkAnchor({
const [menu, setMenu] = React.useState<MediaContextMenuPosition | null>(null);
const closeMenu = React.useCallback(() => setMenu(null), []);
useDismissMediaContextMenu(Boolean(menu), closeMenu);
const openHref = React.useCallback(() => {
if (!href) return;
void openUrl(href).catch(() => {
toast.error("Failed to open link");
});
}, [href]);

const anchor = (
<a
Expand All @@ -46,6 +53,14 @@ export function ExternalLinkAnchor({
isLinearLink ? "linear-link" : "text-primary hover:text-primary/80",
)}
href={href}
onClick={(event) => {
anchorProps.onClick?.(event);
if (event.defaultPrevented || !href || !isObsidianOpenLink(href)) {
return;
}
event.preventDefault();
openHref();
}}
onContextMenuCapture={(event) => {
if (!href) return;
event.preventDefault();
Expand All @@ -71,9 +86,7 @@ export function ExternalLinkAnchor({
label: "Open link",
onSelect: () => {
closeMenu();
void openUrl(href).catch(() => {
toast.error("Failed to open link");
});
openHref();
},
},
{
Expand Down
3 changes: 3 additions & 0 deletions desktop/src/shared/ui/markdown/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { defaultUrlTransform } from "react-markdown";
import { isChannelLink } from "@/features/messages/lib/channelLink";
import { isMessageLink } from "@/features/messages/lib/messageLink";
import { parseEntityLink } from "@/shared/lib/entityLink";
import { isObsidianOpenLink } from "@/shared/lib/obsidianLink";

export function useStableArray<T>(arr: T[]): T[] {
const ref = React.useRef(arr);
Expand Down Expand Up @@ -179,12 +180,14 @@ export function isInsideHiddenSpoiler(element: Element): boolean {
* message-link pill renderer).
* - `buzz://pr|issue|repo` hrefs — preserved only when `parseEntityLink`
* succeeds, keeping the sanitizer active against arbitrary `buzz://` URIs.
* - Valid `obsidian://open` hrefs — preserved for the guarded OS-opener path.
* - Everything else delegates to `defaultUrlTransform`.
*/
export function buzzDeepLinkUrlTransform(value: string, key: string): string {
if (key !== "href") return defaultUrlTransform(value);
if (isMessageLink(value) || isChannelLink(value)) return value;
if (parseEntityLink(value).ok) return value;
if (isObsidianOpenLink(value)) return value;
return defaultUrlTransform(value);
}

Expand Down
38 changes: 38 additions & 0 deletions desktop/tests/e2e/messaging.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,44 @@ test("send a message and see it in timeline", async ({ page }) => {
);
});

test("validated Obsidian link opens through the native OS opener", async ({
page,
}) => {
const link =
"obsidian://open?vault=holistics-digest&file=agent-cowork%2Fprojects%2Fanfra%2FANFRA_SETUP_END_TO_END.canvas";

await page.goto("/");
await page.getByTestId("channel-general").click();
await page.getByTestId("message-input").fill(`[Open in Obsidian](${link})`);
await page.getByTestId("send-message").click();

const anchor = page
.getByTestId("message-row")
.last()
.getByRole("link", { name: "Open in Obsidian" });
await expect(anchor).toHaveAttribute("href", link);
await anchor.click();

await expect
.poll(() =>
page.evaluate(() => {
const calls =
(
window as Window & {
__BUZZ_E2E_COMMAND_LOG__?: Array<{
command: string;
payload: { url?: string };
}>;
}
).__BUZZ_E2E_COMMAND_LOG__ ?? [];
return calls
.filter(({ command }) => command === "plugin:opener|open_url")
.map(({ payload }) => payload.url);
}),
)
.toContain(link);
});

test("long autolink wraps without widening the timeline", async ({ page }) => {
await page.setViewportSize({ width: 800, height: 600 });

Expand Down