fix: harden external link navigation in the web app

- add a shared external-navigation guard at the document level
- route onboarding, markdown, settings, and node source links through one helper
- stop browser fallback code from replacing the current app page on failures

Generated with Codex
This commit is contained in:
“BeeRad”
2026-03-17 18:20:37 +11:00
parent 213c054c9f
commit 7444599817
8 changed files with 164 additions and 41 deletions
+2
View File
@@ -1,5 +1,6 @@
import './globals.css'; import './globals.css';
import { DimensionIconsProvider } from '@/context/DimensionIconsContext'; import { DimensionIconsProvider } from '@/context/DimensionIconsContext';
import ExternalNavigationManager from '@/components/system/ExternalNavigationManager';
export const metadata = { export const metadata = {
title: 'RA-H Open Source', title: 'RA-H Open Source',
@@ -15,6 +16,7 @@ export default function RootLayout({
<html lang="en"> <html lang="en">
<body> <body>
<DimensionIconsProvider> <DimensionIconsProvider>
<ExternalNavigationManager />
{children} {children}
</DimensionIconsProvider> </DimensionIconsProvider>
</body> </body>
+32 -4
View File
@@ -9,6 +9,7 @@ import { parseNodeMarkers } from '@/tools/infrastructure/nodeFormatter';
import { Node, NodeConnection, Chunk } from '@/types/database'; import { Node, NodeConnection, Chunk } from '@/types/database';
import DimensionTags from './dimensions/DimensionTags'; import DimensionTags from './dimensions/DimensionTags';
import { getNodeIcon } from '@/utils/nodeIcons'; import { getNodeIcon } from '@/utils/nodeIcons';
import { openExternalUrl, shouldOpenExternally } from '@/utils/openExternalUrl';
import { useDimensionIcons } from '@/context/DimensionIconsContext'; import { useDimensionIcons } from '@/context/DimensionIconsContext';
import ConfirmDialog from '../common/ConfirmDialog'; import ConfirmDialog from '../common/ConfirmDialog';
import { SourceReader } from './source'; import { SourceReader } from './source';
@@ -1811,13 +1812,23 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
) : nodesData[activeTab].link ? ( ) : nodesData[activeTab].link ? (
<a <a
href={nodesData[activeTab].link} href={nodesData[activeTab].link}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => { onClick={(e) => {
if (e.metaKey || e.ctrlKey) { if (e.metaKey || e.ctrlKey) {
e.preventDefault(); e.preventDefault();
startEdit('link', nodesData[activeTab].link || ''); startEdit('link', nodesData[activeTab].link || '');
return;
} }
const link = nodesData[activeTab].link;
if (!link || !shouldOpenExternally(link)) {
return;
}
e.preventDefault();
void openExternalUrl(link).catch((error) => {
console.error('[FocusPanel] Failed to open node link', error);
window.alert(`Unable to open ${link}`);
});
}} }}
style={{ style={{
color: '#3b82f6', color: '#3b82f6',
@@ -1993,9 +2004,26 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
title="Connections" title="Connections"
> >
<Link size={12} /> <Link size={12} />
{activeTab && edgesData[activeTab] && edgesData[activeTab].length > 0 && ( <span>Edges</span>
{activeTab && (
<span style={{ fontWeight: 600 }}> <span style={{ fontWeight: 600 }}>
{edgesData[activeTab].length} <span
style={{
minWidth: '18px',
height: '18px',
borderRadius: '999px',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
padding: '0 6px',
background: activeContentTab === 'edges' ? 'rgba(34, 197, 94, 0.18)' : '#1a1a1a',
color: activeContentTab === 'edges' ? '#a7f3b8' : '#888',
fontSize: '10px',
lineHeight: 1,
}}
>
{(edgesData[activeTab] || []).length}
</span>
</span> </span>
)} )}
</button> </button>
@@ -3,6 +3,7 @@
import React from 'react'; import React from 'react';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import { openExternalUrl, shouldOpenExternally } from '@/utils/openExternalUrl';
interface NodeLabelInlineProps { interface NodeLabelInlineProps {
id: string; id: string;
@@ -197,8 +198,17 @@ export default function MarkdownWithNodeTokens({ content, onNodeClick }: Markdow
a: ({ href, children }) => ( a: ({ href, children }) => (
<a <a
href={href} href={href}
target="_blank" onClick={(event) => {
rel="noopener noreferrer" if (!href || !shouldOpenExternally(href)) {
return;
}
event.preventDefault();
void openExternalUrl(href).catch((error) => {
console.error('[MarkdownWithNodeTokens] Failed to open external link', error);
window.alert(`Unable to open ${href}`);
});
}}
style={{ color: '#22c55e', textDecoration: 'underline' }} style={{ color: '#22c55e', textDecoration: 'underline' }}
> >
{processChildren(children, 'a')} {processChildren(children, 'a')}
+15 -5
View File
@@ -3,6 +3,7 @@
import { useState, useEffect, type CSSProperties } from 'react'; import { useState, useEffect, type CSSProperties } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { isFirstRun, markFirstRunComplete } from '@/services/storage/apiKeys'; import { isFirstRun, markFirstRunComplete } from '@/services/storage/apiKeys';
import { openExternalUrl } from '@/utils/openExternalUrl';
export default function FirstRunModal() { export default function FirstRunModal() {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
@@ -46,14 +47,18 @@ export default function FirstRunModal() {
<div style={noteStyle}> <div style={noteStyle}>
<p>Without a key, you can still create and organise nodes manually.</p> <p>Without a key, you can still create and organise nodes manually.</p>
<p style={{ marginTop: 8 }}> <p style={{ marginTop: 8 }}>
<a <button
href="https://platform.openai.com/api-keys" type="button"
target="_blank" onClick={() => {
rel="noreferrer" void openExternalUrl('https://platform.openai.com/api-keys').catch((error) => {
console.error('[FirstRunModal] Failed to open OpenAI API keys page', error);
window.alert('Unable to open the OpenAI API keys page automatically.');
});
}}
style={linkStyle} style={linkStyle}
> >
Get an API key from OpenAI Get an API key from OpenAI
</a> </button>
</p> </p>
</div> </div>
</div> </div>
@@ -148,4 +153,9 @@ const noteStyle: CSSProperties = {
const linkStyle: CSSProperties = { const linkStyle: CSSProperties = {
color: '#22c55e', color: '#22c55e',
textDecoration: 'none', textDecoration: 'none',
background: 'transparent',
border: 'none',
padding: 0,
cursor: 'pointer',
font: 'inherit',
}; };
+15 -5
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useState, useEffect, type CSSProperties } from 'react'; import { useState, useEffect, type CSSProperties } from 'react';
import { openExternalUrl } from '@/utils/openExternalUrl';
export default function ApiKeysViewer() { export default function ApiKeysViewer() {
const [status, setStatus] = useState<'checking' | 'configured' | 'not-set'>('checking'); const [status, setStatus] = useState<'checking' | 'configured' | 'not-set'>('checking');
@@ -59,14 +60,18 @@ export default function ApiKeysViewer() {
{/* Get key link */} {/* Get key link */}
<div style={helpStyle}> <div style={helpStyle}>
<a <button
href="https://platform.openai.com/api-keys" type="button"
target="_blank" onClick={() => {
rel="noreferrer" void openExternalUrl('https://platform.openai.com/api-keys').catch((error) => {
console.error('[ApiKeysViewer] Failed to open OpenAI API keys page', error);
window.alert('Unable to open the OpenAI API keys page automatically.');
});
}}
style={linkStyle} style={linkStyle}
> >
Get your API key from OpenAI Get your API key from OpenAI
</a> </button>
</div> </div>
</div> </div>
); );
@@ -163,4 +168,9 @@ const helpStyle: CSSProperties = {
const linkStyle: CSSProperties = { const linkStyle: CSSProperties = {
color: '#22c55e', color: '#22c55e',
textDecoration: 'none', textDecoration: 'none',
background: 'transparent',
border: 'none',
padding: 0,
cursor: 'pointer',
font: 'inherit',
}; };
+7 -1
View File
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useMemo, useState, type ChangeEvent, type KeyboardEvent } from 'react'; import { useCallback, useEffect, useMemo, useState, type ChangeEvent, type KeyboardEvent } from 'react';
import { Folder, Link as LinkIcon } from 'lucide-react'; import { Folder, Link as LinkIcon } from 'lucide-react';
import { Node } from '@/types/database'; import { Node } from '@/types/database';
import { openExternalUrl } from '@/utils/openExternalUrl';
interface NodeWithMetrics extends Node { interface NodeWithMetrics extends Node {
edge_count?: number; edge_count?: number;
@@ -440,7 +441,12 @@ export default function DatabaseViewer() {
{node.title || 'Untitled node'} {node.title || 'Untitled node'}
{node.link && ( {node.link && (
<button <button
onClick={() => window.open(node.link, '_blank')} onClick={() => {
void openExternalUrl(node.link!).catch((error) => {
console.error('[DatabaseViewer] Failed to open node link', error);
window.alert(`Unable to open ${node.link}`);
});
}}
title="Open original link" title="Open original link"
style={{ style={{
background: 'transparent', background: 'transparent',
@@ -0,0 +1,48 @@
"use client";
import { useEffect } from "react";
import { openExternalUrl, shouldOpenExternally } from "@/utils/openExternalUrl";
export default function ExternalNavigationManager() {
useEffect(() => {
const handleDocumentClick = (event: MouseEvent) => {
if (event.defaultPrevented || event.button !== 0) {
return;
}
const target = event.target;
if (!(target instanceof Element)) {
return;
}
const anchor = target.closest("a[href]");
if (!(anchor instanceof HTMLAnchorElement)) {
return;
}
const rawHref = anchor.getAttribute("href");
if (!rawHref || rawHref.startsWith("#") || anchor.hasAttribute("download")) {
return;
}
if (!shouldOpenExternally(rawHref)) {
return;
}
event.preventDefault();
event.stopPropagation();
void openExternalUrl(anchor.href || rawHref).catch((error) => {
console.error("[external] document click open failed", error);
window.alert(`Unable to open this link automatically.\n\n${anchor.href || rawHref}`);
});
};
document.addEventListener("click", handleDocumentClick, true);
return () => {
document.removeEventListener("click", handleDocumentClick, true);
};
}, []);
return null;
}
+33 -24
View File
@@ -1,38 +1,47 @@
"use client"; "use client";
const LOCAL_HOSTS = new Set(["127.0.0.1", "localhost", "0.0.0.0"]);
function resolveUrl(rawUrl: string): URL | null {
if (typeof window === "undefined") return null;
try {
return new URL(rawUrl, window.location.href);
} catch (error) {
console.error("[external] failed to parse url", rawUrl, error);
return null;
}
}
function isLocalAppUrl(url: URL) {
if (!["http:", "https:"].includes(url.protocol)) {
return false;
}
return LOCAL_HOSTS.has(url.hostname) || url.hostname === window.location.hostname;
}
export async function openExternalUrl(url: string) { export async function openExternalUrl(url: string) {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
try { const resolvedUrl = resolveUrl(url)?.toString() ?? url;
const newWindow = window.open(url, "_blank", "noopener,noreferrer"); const newWindow = window.open(resolvedUrl, "_blank", "noopener,noreferrer");
if (!newWindow) { if (!newWindow) {
window.location.href = url; throw new Error("browser_open_blocked");
}
} catch (error) {
console.error("[external] window.open failed", error);
window.location.href = url;
} }
} }
export function shouldOpenExternally(rawUrl: string) { export function shouldOpenExternally(rawUrl: string) {
if (typeof window === "undefined") return false; if (typeof window === "undefined") return false;
try { const resolved = resolveUrl(rawUrl);
const resolved = new URL(rawUrl, window.location.href); if (!resolved) {
if (!["http:", "https:"].includes(resolved.protocol)) {
return false;
}
const localhostHosts = new Set([
window.location.hostname,
"127.0.0.1",
"localhost",
"0.0.0.0",
]);
return !localhostHosts.has(resolved.hostname);
} catch (error) {
console.error("[external] failed to parse url", rawUrl, error);
return false; return false;
} }
if (!["http:", "https:"].includes(resolved.protocol)) {
return false;
}
return !isLocalAppUrl(resolved);
} }