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:
@@ -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>
|
||||||
|
|||||||
@@ -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')}
|
||||||
|
|||||||
@@ -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',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user