Skip to content

Commit bbe95dc

Browse files
authored
Merge pull request #390 from cloudflare/nightly
Nightly -> Main
2 parents e358598 + 854676e commit bbe95dc

26 files changed

Lines changed: 1097 additions & 104 deletions

src/lib/utils.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { getRegistrableDomain } from './utils';
3+
4+
// Note: `isCrossSitePreview` and `isAppleWebKitBrowser` depend on `window`/
5+
// `navigator` and are exercised in the browser, not this workers-pool harness.
6+
// The registrable-domain heuristic they build on is pure and covered here.
7+
describe('getRegistrableDomain', () => {
8+
it('returns the eTLD+1 for simple hosts', () => {
9+
expect(getRegistrableDomain('app.example.com')).toBe('example.com');
10+
expect(getRegistrableDomain('preview.example.com')).toBe('example.com');
11+
expect(getRegistrableDomain('example.com')).toBe('example.com');
12+
});
13+
14+
it('treats different registrable domains as distinct', () => {
15+
expect(getRegistrableDomain('myapp.com')).not.toBe(
16+
getRegistrableDomain('mypreview.dev'),
17+
);
18+
});
19+
20+
it('handles common multi-part public suffixes', () => {
21+
expect(getRegistrableDomain('app.example.co.uk')).toBe('example.co.uk');
22+
expect(getRegistrableDomain('preview.example.com.au')).toBe('example.com.au');
23+
});
24+
25+
it('strips the port and trailing dot, and lowercases', () => {
26+
expect(getRegistrableDomain('App.Example.com:8080')).toBe('example.com');
27+
expect(getRegistrableDomain('example.com.')).toBe('example.com');
28+
});
29+
30+
it('handles single-label and empty hosts', () => {
31+
expect(getRegistrableDomain('localhost')).toBe('localhost');
32+
expect(getRegistrableDomain('')).toBe('');
33+
});
34+
});

src/lib/utils.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,80 @@ export function capitalizeFirstLetter(str: string) {
1515
return str; // Handle non-string input or empty string
1616
}
1717
return str.charAt(0).toUpperCase() + str.slice(1);
18+
}
19+
20+
/**
21+
* Detect Apple/WebKit browsers subject to Intelligent Tracking Prevention
22+
* (ITP), which blocks third-party cookies for an origin embedded in a
23+
* cross-site iframe. This covers desktop Safari and every iOS/iPadOS browser
24+
* (all use WebKit). Best-effort UA sniffing; used only to decide whether to
25+
* show an advisory banner.
26+
*/
27+
export function isAppleWebKitBrowser(): boolean {
28+
if (typeof navigator === 'undefined') return false;
29+
const ua = navigator.userAgent;
30+
const isIOS = /iPhone|iPad|iPod/.test(ua);
31+
// iPadOS 13+ reports as "Macintosh" but exposes touch points.
32+
const isIPadOS =
33+
/Macintosh/.test(ua) &&
34+
typeof navigator.maxTouchPoints === 'number' &&
35+
navigator.maxTouchPoints > 1;
36+
if (isIOS || isIPadOS) return true;
37+
const isDesktopSafari =
38+
/Safari/.test(ua) && !/Chrome|Chromium|CriOS|FxiOS|Edg|Android|OPR/.test(ua);
39+
return isDesktopSafari;
40+
}
41+
42+
const MULTI_PART_SUFFIXES = [
43+
'co.uk',
44+
'org.uk',
45+
'gov.uk',
46+
'ac.uk',
47+
'com.au',
48+
'net.au',
49+
'org.au',
50+
'co.in',
51+
'co.jp',
52+
'co.nz',
53+
'co.za',
54+
'com.br',
55+
];
56+
57+
/**
58+
* Best-effort registrable domain (eTLD+1) for a host. Strips the port and
59+
* returns the last two labels, accounting for a small set of common
60+
* multi-part public suffixes. Heuristic only (no full Public Suffix List);
61+
* used to decide whether a preview iframe is cross-site for an advisory
62+
* banner, never for a security decision.
63+
*/
64+
export function getRegistrableDomain(host: string): string {
65+
if (!host) return '';
66+
const bare = host.split(':')[0].toLowerCase().replace(/\.$/, '');
67+
const labels = bare.split('.').filter(Boolean);
68+
if (labels.length <= 2) return labels.join('.');
69+
const lastTwo = labels.slice(-2).join('.');
70+
if (MULTI_PART_SUFFIXES.includes(lastTwo)) {
71+
return labels.slice(-3).join('.');
72+
}
73+
return lastTwo;
74+
}
75+
76+
/**
77+
* True when the preview URL is served from a different registrable domain than
78+
* the current dashboard page (the case where Safari blocks the cross-site
79+
* preview cookie). Returns false for same-origin, same-base-domain subdomains,
80+
* or unparseable input.
81+
*/
82+
export function isCrossSitePreview(previewUrl: string): boolean {
83+
if (!previewUrl || typeof window === 'undefined') return false;
84+
try {
85+
const previewHost = new URL(previewUrl).host;
86+
const appHost = window.location.host;
87+
const previewDomain = getRegistrableDomain(previewHost);
88+
const appDomain = getRegistrableDomain(appHost);
89+
if (!previewDomain || !appDomain) return false;
90+
return previewDomain !== appDomain;
91+
} catch {
92+
return false;
93+
}
1894
}

src/routes/chat/chat.tsx

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
useState,
77
type FormEvent,
88
} from 'react';
9-
import { useParams, useSearchParams, useNavigate } from 'react-router';
9+
import { useParams, useSearchParams, useNavigate, useLocation } from 'react-router';
1010
import { AnimatePresence, motion } from 'framer-motion';
1111
import { LoaderCircle, MoreHorizontal, RotateCcw } from 'lucide-react';
1212
import clsx from 'clsx';
@@ -63,10 +63,19 @@ export default function Chat() {
6363
const { chatId: urlChatId } = useParams();
6464

6565
const [searchParams] = useSearchParams();
66+
const location = useLocation();
6667
const userQuery = searchParams.get('query');
6768
const urlProjectType = searchParams.get('projectType') || 'app';
6869
const urlBehaviorType = searchParams.get('behaviorType') as BehaviorType | null;
6970

71+
// Only auto-start a brand-new session when it originated from in-app
72+
// navigation (e.g. the home prompt box sets `fromPrompt`). Sessions opened
73+
// from a pasted/external link require explicit confirmation, since the
74+
// query is interpolated into the agent's system prompt.
75+
const startedFromInApp =
76+
(location.state as { fromPrompt?: boolean } | null)?.fromPrompt === true;
77+
const autoStart = urlChatId !== 'new' || startedFromInApp;
78+
7079
// Extract images from URL params if present
7180
const userImages = useMemo(() => {
7281
const imagesParam = searchParams.get('images');
@@ -171,12 +180,16 @@ export default function Chat() {
171180
// Backend error dialog state
172181
backendErrorDialog,
173182
setBackendErrorDialog,
183+
// Externally-sourced session start gate
184+
awaitingStartConfirmation,
185+
confirmStart,
174186
} = useChat({
175187
chatId: urlChatId,
176188
query: userQuery,
177189
images: userImages,
178190
projectType: urlProjectType as ProjectType,
179191
behaviorType: urlBehaviorType ?? undefined,
192+
autoStart,
180193
onDebugMessage: addDebugMessage,
181194
onVaultUnlockRequired: handleVaultUnlockRequired,
182195
});
@@ -693,6 +706,31 @@ export default function Chat() {
693706
});
694707
}
695708

709+
if (awaitingStartConfirmation) {
710+
return (
711+
<div className="size-full flex items-center justify-center p-6 text-text-primary">
712+
<div className="max-w-lg w-full flex flex-col gap-4 rounded-xl border border-border-primary bg-bg-2 p-6">
713+
<h1 className="text-lg font-medium">Start building this app?</h1>
714+
<p className="text-sm text-text-secondary">
715+
This link wants to start a new project with the prompt below.
716+
Review it before continuing.
717+
</p>
718+
<div className="rounded-lg border border-border-primary bg-bg-3 p-4 max-h-64 overflow-y-auto">
719+
<p className="text-sm text-text-primary whitespace-pre-wrap break-words">
720+
{displayQuery}
721+
</p>
722+
</div>
723+
<div className="flex items-center justify-end gap-2">
724+
<Button variant="outline" onClick={() => navigate('/')}>
725+
Cancel
726+
</Button>
727+
<Button onClick={confirmStart}>Start building</Button>
728+
</div>
729+
</div>
730+
</div>
731+
);
732+
}
733+
696734
return (
697735
<div className="size-full flex flex-col min-h-0 text-text-primary">
698736
<div className="flex-1 flex min-h-0 overflow-hidden justify-center">

src/routes/chat/components/main-content-panel.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { RefreshCw } from 'lucide-react';
66
import { Blueprint } from './blueprint';
77
import { FileExplorer } from './file-explorer';
88
import { PreviewIframe } from './preview-iframe';
9+
import { PreviewCompatBanner } from './preview-compat-banner';
910
import { MarkdownDocsPreview } from './markdown-docs-preview';
1011
import { ViewContainer } from './view-container';
1112
import { ViewHeader } from './view-header';
@@ -282,7 +283,10 @@ export function MainContentPanel(props: MainContentPanelProps) {
282283
</button>
283284
)}
284285
</div>,
285-
previewContent,
286+
<div className="flex flex-1 min-h-0 flex-col">
287+
<PreviewCompatBanner previewUrl={previewUrl} />
288+
<div className="relative flex flex-1 min-h-0 flex-col">{previewContent}</div>
289+
</div>,
286290
headerActions
287291
);
288292
};
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { type Components } from 'react-markdown';
2+
3+
/**
4+
* ReactMarkdown component overrides that disable image rendering. Model and
5+
* user-authored markdown can contain `![](url)` which renders an outbound
6+
* `<img>` request; this is an invisible data-exfiltration channel for prompt
7+
* injection. Rendering images as null breaks that channel while preserving all
8+
* other markdown.
9+
*/
10+
export const NO_IMAGE_MARKDOWN_COMPONENTS: Components = {
11+
img: () => null,
12+
};

src/routes/chat/components/markdown-docs-preview.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import ReactMarkdown from 'react-markdown';
44
import remarkGfm from 'remark-gfm';
55
import rehypeExternalLinks from 'rehype-external-links';
66
import { DocsSidebar } from './docs-sidebar';
7+
import { NO_IMAGE_MARKDOWN_COMPONENTS } from './markdown-components';
78
import { ExportButton } from './export-button';
89
import { exportMarkdownAsFile } from '@/utils/markdown-export';
910
import type { FileType } from '@/api-types';
@@ -154,6 +155,7 @@ export function MarkdownDocsPreview({
154155
remarkPlugins={[remarkGfm]}
155156
rehypePlugins={[[rehypeExternalLinks, { target: '_blank' }]]}
156157
components={{
158+
...NO_IMAGE_MARKDOWN_COMPONENTS,
157159
h1: ({ node, ...props }) => (
158160
<h1 id={createId(props.children)} {...props} />
159161
),

src/routes/chat/components/messages.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import clsx from 'clsx';
33
import ReactMarkdown from 'react-markdown';
44
import remarkGfm from 'remark-gfm';
55
import rehypeExternalLinks from 'rehype-external-links';
6+
import { NO_IMAGE_MARKDOWN_COMPONENTS } from './markdown-components';
67
import { LoaderCircle, Check, AlertTriangle, ChevronDown, ChevronRight, MessageSquare } from 'lucide-react';
78
import type { ToolEvent } from '../utils/message-helpers';
89
import type { ConversationMessage } from '@/api-types';
@@ -506,6 +507,7 @@ export function Markdown({ children, className, ...props }: MarkdownProps) {
506507
<ReactMarkdown
507508
remarkPlugins={[remarkGfm]}
508509
rehypePlugins={[[rehypeExternalLinks, { target: '_blank' }]]}
510+
components={NO_IMAGE_MARKDOWN_COMPONENTS}
509511
>
510512
{children}
511513
</ReactMarkdown>
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { useMemo, useState } from 'react';
2+
import { ExternalLink, TriangleAlert, X } from 'lucide-react';
3+
import { Alert, AlertDescription } from '@/components/ui/alert';
4+
import { Button } from '@/components/ui/button';
5+
import { isAppleWebKitBrowser, isCrossSitePreview } from '@/lib/utils';
6+
7+
const DISMISS_KEY = 'preview-compat-dismissed';
8+
9+
function readDismissed(): boolean {
10+
try {
11+
return sessionStorage.getItem(DISMISS_KEY) === '1';
12+
} catch {
13+
return false;
14+
}
15+
}
16+
17+
interface PreviewCompatBannerProps {
18+
previewUrl: string;
19+
}
20+
21+
/**
22+
* Advisory banner shown above the preview when the viewer is on an Apple/WebKit
23+
* browser AND the preview runs on a different registrable domain than the
24+
* dashboard. In that case Safari blocks the cross-site preview cookie, so the
25+
* embedded iframe's dynamic requests can fail. Opening the preview in a new tab
26+
* loads it first-party, where it works fully.
27+
*/
28+
export function PreviewCompatBanner({ previewUrl }: PreviewCompatBannerProps) {
29+
const [dismissed, setDismissed] = useState(readDismissed);
30+
31+
const show = useMemo(
32+
() => isAppleWebKitBrowser() && isCrossSitePreview(previewUrl),
33+
[previewUrl],
34+
);
35+
36+
if (!show || dismissed) {
37+
return null;
38+
}
39+
40+
const dismiss = () => {
41+
try {
42+
sessionStorage.setItem(DISMISS_KEY, '1');
43+
} catch {
44+
// Ignore storage failures; banner just won't persist its dismissal.
45+
}
46+
setDismissed(true);
47+
};
48+
49+
return (
50+
<Alert className="rounded-none border-x-0 border-t-0 flex items-center gap-3">
51+
<TriangleAlert className="text-amber-500" />
52+
<AlertDescription className="flex-1">
53+
Preview may not fully load in Safari due to preview being served from a
54+
different domain.
55+
</AlertDescription>
56+
<Button
57+
variant="outline"
58+
size="sm"
59+
onClick={() =>
60+
window.open(previewUrl, '_blank', 'noopener,noreferrer')
61+
}
62+
>
63+
<ExternalLink />
64+
Open in new tab
65+
</Button>
66+
<button
67+
type="button"
68+
aria-label="Dismiss"
69+
onClick={dismiss}
70+
className="p-1 rounded hover:bg-bg-2 transition-colors"
71+
>
72+
<X className="size-4 text-text-primary/60" />
73+
</button>
74+
</Alert>
75+
);
76+
}

0 commit comments

Comments
 (0)