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 { DimensionIconsProvider } from '@/context/DimensionIconsContext';
|
||||
import ExternalNavigationManager from '@/components/system/ExternalNavigationManager';
|
||||
|
||||
export const metadata = {
|
||||
title: 'RA-H Open Source',
|
||||
@@ -15,6 +16,7 @@ export default function RootLayout({
|
||||
<html lang="en">
|
||||
<body>
|
||||
<DimensionIconsProvider>
|
||||
<ExternalNavigationManager />
|
||||
{children}
|
||||
</DimensionIconsProvider>
|
||||
</body>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { parseNodeMarkers } from '@/tools/infrastructure/nodeFormatter';
|
||||
import { Node, NodeConnection, Chunk } from '@/types/database';
|
||||
import DimensionTags from './dimensions/DimensionTags';
|
||||
import { getNodeIcon } from '@/utils/nodeIcons';
|
||||
import { openExternalUrl, shouldOpenExternally } from '@/utils/openExternalUrl';
|
||||
import { useDimensionIcons } from '@/context/DimensionIconsContext';
|
||||
import ConfirmDialog from '../common/ConfirmDialog';
|
||||
import { SourceReader } from './source';
|
||||
@@ -1811,13 +1812,23 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
||||
) : nodesData[activeTab].link ? (
|
||||
<a
|
||||
href={nodesData[activeTab].link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => {
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
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={{
|
||||
color: '#3b82f6',
|
||||
@@ -1993,9 +2004,26 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
||||
title="Connections"
|
||||
>
|
||||
<Link size={12} />
|
||||
{activeTab && edgesData[activeTab] && edgesData[activeTab].length > 0 && (
|
||||
<span>Edges</span>
|
||||
{activeTab && (
|
||||
<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>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import React from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { openExternalUrl, shouldOpenExternally } from '@/utils/openExternalUrl';
|
||||
|
||||
interface NodeLabelInlineProps {
|
||||
id: string;
|
||||
@@ -197,8 +198,17 @@ export default function MarkdownWithNodeTokens({ content, onNodeClick }: Markdow
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(event) => {
|
||||
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' }}
|
||||
>
|
||||
{processChildren(children, 'a')}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect, type CSSProperties } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { isFirstRun, markFirstRunComplete } from '@/services/storage/apiKeys';
|
||||
import { openExternalUrl } from '@/utils/openExternalUrl';
|
||||
|
||||
export default function FirstRunModal() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@@ -46,14 +47,18 @@ export default function FirstRunModal() {
|
||||
<div style={noteStyle}>
|
||||
<p>Without a key, you can still create and organise nodes manually.</p>
|
||||
<p style={{ marginTop: 8 }}>
|
||||
<a
|
||||
href="https://platform.openai.com/api-keys"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
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}
|
||||
>
|
||||
Get an API key from OpenAI →
|
||||
</a>
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -148,4 +153,9 @@ const noteStyle: CSSProperties = {
|
||||
const linkStyle: CSSProperties = {
|
||||
color: '#22c55e',
|
||||
textDecoration: 'none',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
cursor: 'pointer',
|
||||
font: 'inherit',
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, type CSSProperties } from 'react';
|
||||
import { openExternalUrl } from '@/utils/openExternalUrl';
|
||||
|
||||
export default function ApiKeysViewer() {
|
||||
const [status, setStatus] = useState<'checking' | 'configured' | 'not-set'>('checking');
|
||||
@@ -59,14 +60,18 @@ export default function ApiKeysViewer() {
|
||||
|
||||
{/* Get key link */}
|
||||
<div style={helpStyle}>
|
||||
<a
|
||||
href="https://platform.openai.com/api-keys"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
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}
|
||||
>
|
||||
Get your API key from OpenAI →
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -163,4 +168,9 @@ const helpStyle: CSSProperties = {
|
||||
const linkStyle: CSSProperties = {
|
||||
color: '#22c55e',
|
||||
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 { Folder, Link as LinkIcon } from 'lucide-react';
|
||||
import { Node } from '@/types/database';
|
||||
import { openExternalUrl } from '@/utils/openExternalUrl';
|
||||
|
||||
interface NodeWithMetrics extends Node {
|
||||
edge_count?: number;
|
||||
@@ -440,7 +441,12 @@ export default function DatabaseViewer() {
|
||||
{node.title || 'Untitled node'}
|
||||
{node.link && (
|
||||
<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"
|
||||
style={{
|
||||
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";
|
||||
|
||||
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) {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
try {
|
||||
const newWindow = window.open(url, "_blank", "noopener,noreferrer");
|
||||
if (!newWindow) {
|
||||
window.location.href = url;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[external] window.open failed", error);
|
||||
window.location.href = url;
|
||||
const resolvedUrl = resolveUrl(url)?.toString() ?? url;
|
||||
const newWindow = window.open(resolvedUrl, "_blank", "noopener,noreferrer");
|
||||
if (!newWindow) {
|
||||
throw new Error("browser_open_blocked");
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldOpenExternally(rawUrl: string) {
|
||||
if (typeof window === "undefined") return false;
|
||||
|
||||
try {
|
||||
const resolved = new URL(rawUrl, window.location.href);
|
||||
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);
|
||||
const resolved = resolveUrl(rawUrl);
|
||||
if (!resolved) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!["http:", "https:"].includes(resolved.protocol)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !isLocalAppUrl(resolved);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user