mirror of
https://github.com/cline/cline.git
synced 2026-09-04 20:02:30 +08:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c2257d3bc1 | |||
| a0b409ee4f | |||
| 2c54bd510a | |||
| 37690c3834 | |||
| cac4eee5ec | |||
| d9e383a546 | |||
| e1c93847ce | |||
| e7208c6a2f | |||
| 885ab233cf | |||
| 7b353073ad | |||
| 8b7036cb2a | |||
| 7ce23fd6a9 | |||
| b020cf6826 | |||
| beac41e329 | |||
| b52ca98421 | |||
| b228462f0f | |||
| f7e4978ce6 | |||
| a5db3683c1 | |||
| c1ba3c43d5 | |||
| 27b0997ad8 | |||
| 854ab0ca87 | |||
| 9a37752314 | |||
| b27990b204 | |||
| 807e8cd519 | |||
| eeeedd6863 | |||
| 73c9c83597 | |||
| ff9760f1d7 | |||
| 52395b4b7f | |||
| 906d39c236 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Start using ErrorBoundaries for chat widgets to handle crashes
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Harden MCP Response Display with network throttling, ErrorBoundaries, URL checking, HTTPs security, limit number of URLs parsed per response. Adds support for svg, webp, and gifs
|
||||
@@ -63,7 +63,6 @@ export async function fetchOpenGraphData(url: string): Promise<OpenGraphData> {
|
||||
type: data.ogType,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error fetching Open Graph data for ${url}:`, error)
|
||||
// Return basic information based on the URL
|
||||
try {
|
||||
const urlObj = new URL(url)
|
||||
@@ -100,8 +99,7 @@ export async function isImageUrl(url: string): Promise<boolean> {
|
||||
const contentType = response.headers["content-type"]
|
||||
return contentType && contentType.startsWith("image/")
|
||||
} catch (error) {
|
||||
console.error(`Error checking if URL is an image: ${url}`, error)
|
||||
// If we can't determine, fall back to checking the file extension
|
||||
return /\.(jpg|jpeg|png|gif|webp|svg)$/i.test(url)
|
||||
return /\.(jpg|jpeg|png|gif|webp|bmp|svg|tiff|tif|avif)$/i.test(url)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import React from "react"
|
||||
|
||||
interface ChatErrorBoundaryProps {
|
||||
children: React.ReactNode
|
||||
errorTitle?: string
|
||||
errorBody?: string
|
||||
height?: string
|
||||
}
|
||||
|
||||
interface ChatErrorBoundaryState {
|
||||
hasError: boolean
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
/**
|
||||
* A reusable error boundary component specifically designed for chat widgets.
|
||||
* It provides a consistent error UI with customizable title and body text.
|
||||
*/
|
||||
export class ChatErrorBoundary extends React.Component<ChatErrorBoundaryProps, ChatErrorBoundaryState> {
|
||||
constructor(props: ChatErrorBoundaryProps) {
|
||||
super(props)
|
||||
this.state = { hasError: false, error: null }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error("Error in ChatErrorBoundary:", error.message)
|
||||
console.error("Component stack:", errorInfo.componentStack)
|
||||
}
|
||||
|
||||
render() {
|
||||
const { errorTitle, errorBody, height } = this.props
|
||||
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px",
|
||||
color: "var(--vscode-errorForeground)",
|
||||
height: height || "auto",
|
||||
maxWidth: "512px",
|
||||
overflow: "auto",
|
||||
border: "1px solid var(--vscode-editorError-foreground)",
|
||||
borderRadius: "4px",
|
||||
backgroundColor: "var(--vscode-inputValidation-errorBackground, rgba(255, 0, 0, 0.1))",
|
||||
}}>
|
||||
<h3 style={{ margin: "0 0 8px 0" }}>{errorTitle || "Something went wrong displaying this content"}</h3>
|
||||
<p style={{ margin: "0" }}>{errorBody || `Error: ${this.state.error?.message || "Unknown error"}`}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A demo component that throws an error after a delay.
|
||||
* This is useful for testing error boundaries during development
|
||||
*/
|
||||
interface ErrorAfterDelayProps {
|
||||
numSecondsToWait?: number
|
||||
}
|
||||
|
||||
interface ErrorAfterDelayState {
|
||||
tickCount: number
|
||||
}
|
||||
|
||||
export class ErrorAfterDelay extends React.Component<ErrorAfterDelayProps, ErrorAfterDelayState> {
|
||||
private intervalID: NodeJS.Timeout | null = null
|
||||
|
||||
constructor(props: ErrorAfterDelayProps) {
|
||||
super(props)
|
||||
this.state = {
|
||||
tickCount: 0,
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const secondsToWait = this.props.numSecondsToWait ?? 5
|
||||
|
||||
this.intervalID = setInterval(() => {
|
||||
if (this.state.tickCount >= secondsToWait) {
|
||||
if (this.intervalID) {
|
||||
clearInterval(this.intervalID)
|
||||
}
|
||||
// Error boundaries don't catch async code :(
|
||||
// So this only works by throwing inside of a setState
|
||||
this.setState(() => {
|
||||
throw new Error("This is an error for testing the error boundary")
|
||||
})
|
||||
} else {
|
||||
this.setState({
|
||||
tickCount: this.state.tickCount + 1,
|
||||
})
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.intervalID) {
|
||||
clearInterval(this.intervalID)
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
// Add a small visual indicator that this component will cause an error
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
background: "rgba(255, 0, 0, 0.5)",
|
||||
color: "var(--vscode-errorForeground)",
|
||||
padding: "2px 5px",
|
||||
fontSize: "12px",
|
||||
borderRadius: "0 0 0 4px",
|
||||
zIndex: 100,
|
||||
}}>
|
||||
Error in {this.state.tickCount}/{this.props.numSecondsToWait ?? 5} seconds
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default ChatErrorBoundary
|
||||
@@ -25,6 +25,7 @@ import MarkdownBlock from "../common/MarkdownBlock"
|
||||
import Thumbnails from "../common/Thumbnails"
|
||||
import McpResourceRow from "../mcp/McpResourceRow"
|
||||
import McpToolRow from "../mcp/McpToolRow"
|
||||
import McpResponseDisplay from "../mcp/McpResponseDisplay"
|
||||
import CreditLimitError from "./CreditLimitError"
|
||||
import { OptionsButtons } from "./OptionsButtons"
|
||||
import { highlightMentions } from "./TaskHeader"
|
||||
@@ -792,30 +793,8 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
|
||||
)
|
||||
case "api_req_finished":
|
||||
return null // we should never see this message type
|
||||
// case "mcp_server_response":
|
||||
// return <McpResponseDisplay responseText={message.text || ""} />
|
||||
case "mcp_server_response":
|
||||
return (
|
||||
<>
|
||||
<div style={{ paddingTop: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: "4px",
|
||||
opacity: 0.8,
|
||||
fontSize: "12px",
|
||||
textTransform: "uppercase",
|
||||
}}>
|
||||
Response
|
||||
</div>
|
||||
<CodeAccordian
|
||||
code={message.text}
|
||||
language="json"
|
||||
isExpanded={true}
|
||||
onToggleExpand={onToggleExpand}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
return <McpResponseDisplay responseText={message.text || ""} />
|
||||
case "text":
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
import React, { useEffect, useRef } from "react"
|
||||
import { vscode } from "../../utils/vscode"
|
||||
import DOMPurify from "dompurify"
|
||||
import { getSafeHostname, formatUrlForOpening, checkIfImageUrl } from "./McpRichUtil"
|
||||
import ChatErrorBoundary from "../chat/ChatErrorBoundary"
|
||||
|
||||
interface ImagePreviewProps {
|
||||
url: string
|
||||
}
|
||||
|
||||
// Use a class component to ensure complete isolation between instances
|
||||
class ImagePreview extends React.Component<
|
||||
ImagePreviewProps,
|
||||
{
|
||||
loading: boolean
|
||||
error: string | null
|
||||
fetchStartTime: number
|
||||
}
|
||||
> {
|
||||
private imgRef = React.createRef<HTMLImageElement>()
|
||||
private timeoutId: NodeJS.Timeout | null = null
|
||||
private heartbeatId: NodeJS.Timeout | null = null
|
||||
|
||||
constructor(props: ImagePreviewProps) {
|
||||
super(props)
|
||||
this.state = {
|
||||
loading: true,
|
||||
error: null,
|
||||
fetchStartTime: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
// Track aspect ratio for proper display
|
||||
private aspectRatio: number = 1
|
||||
|
||||
componentDidMount() {
|
||||
// Set up a timeout to handle cases where the image never loads or errors
|
||||
this.timeoutId = setTimeout(() => {
|
||||
console.log(`Image load timeout for ${this.props.url}`)
|
||||
if (this.state.loading) {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: `Timeout loading image: ${this.props.url}`,
|
||||
})
|
||||
}
|
||||
}, 15000)
|
||||
|
||||
// Set up a heartbeat to update the UI with elapsed time
|
||||
this.heartbeatId = setInterval(() => {
|
||||
if (this.state.loading) {
|
||||
this.forceUpdate() // Just update the component to show new elapsed time
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
// First, check the content type to verify it's actually an image
|
||||
this.checkContentType(this.props.url)
|
||||
}
|
||||
|
||||
// Check if the URL is an image using content type verification
|
||||
checkContentType(url: string) {
|
||||
// Always verify content type, even for URLs that look like images by extension
|
||||
checkIfImageUrl(url)
|
||||
.then((isImage) => {
|
||||
if (isImage) {
|
||||
console.log(`URL is confirmed as image: ${url}`)
|
||||
this.loadImage(url)
|
||||
} else {
|
||||
console.log(`URL is not an image: ${url}`)
|
||||
this.handleImageError()
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(`Error checking if URL is an image: ${error}`)
|
||||
// Don't fallback to direct image loading on error
|
||||
// Instead, report the error so the URL can be handled as a non-image
|
||||
this.handleImageError()
|
||||
})
|
||||
}
|
||||
|
||||
// Load the image after content type check or as fallback
|
||||
loadImage(url: string) {
|
||||
const isSvg = /\.svg(\?.*)?$/i.test(url)
|
||||
|
||||
// For SVG files, we don't need to calculate aspect ratio as they're vector-based
|
||||
if (isSvg) {
|
||||
console.log(`SVG image detected, skipping aspect ratio calculation: ${url}`)
|
||||
// Default aspect ratio for SVGs
|
||||
this.aspectRatio = 1
|
||||
this.handleImageLoad()
|
||||
return
|
||||
}
|
||||
|
||||
// Create a test image to check if the URL loads and get dimensions
|
||||
const testImg = new Image()
|
||||
|
||||
testImg.onload = () => {
|
||||
console.log(`Test image loaded successfully: ${url}`)
|
||||
|
||||
// Calculate aspect ratio for proper display
|
||||
if (testImg.width > 0 && testImg.height > 0) {
|
||||
this.aspectRatio = testImg.width / testImg.height
|
||||
}
|
||||
|
||||
this.handleImageLoad()
|
||||
}
|
||||
|
||||
testImg.onerror = () => {
|
||||
console.log(`Test image failed to load: ${url}`)
|
||||
this.handleImageError()
|
||||
}
|
||||
|
||||
// Force CORS mode to be anonymous to avoid CORS issues
|
||||
testImg.crossOrigin = "anonymous"
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.cleanup()
|
||||
}
|
||||
|
||||
private cleanup() {
|
||||
if (this.timeoutId) {
|
||||
clearTimeout(this.timeoutId)
|
||||
this.timeoutId = null
|
||||
}
|
||||
|
||||
if (this.heartbeatId) {
|
||||
clearInterval(this.heartbeatId)
|
||||
this.heartbeatId = null
|
||||
}
|
||||
}
|
||||
|
||||
// Handle image load event
|
||||
handleImageLoad = () => {
|
||||
console.log(`Image loaded successfully: ${this.props.url}`)
|
||||
this.setState({ loading: false })
|
||||
this.cleanup()
|
||||
}
|
||||
|
||||
// Handle image error event
|
||||
handleImageError = () => {
|
||||
console.log(`Image failed to load: ${this.props.url}`)
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: `Failed to load image: ${this.props.url}`,
|
||||
})
|
||||
this.cleanup()
|
||||
}
|
||||
|
||||
render() {
|
||||
const { url } = this.props
|
||||
const { loading, error, fetchStartTime } = this.state
|
||||
|
||||
// Calculate elapsed time for loading state
|
||||
const elapsedSeconds = loading ? Math.floor((Date.now() - fetchStartTime) / 1000) : 0
|
||||
|
||||
// Fallback display while loading
|
||||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
className="image-preview-loading"
|
||||
style={{
|
||||
padding: "12px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
border: "1px solid var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.3))",
|
||||
borderRadius: "4px",
|
||||
height: "128px",
|
||||
maxWidth: "512px",
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "center", marginBottom: "8px" }}>
|
||||
<div
|
||||
className="loading-spinner"
|
||||
style={{
|
||||
marginRight: "8px",
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
border: "2px solid rgba(127, 127, 127, 0.3)",
|
||||
borderTopColor: "var(--vscode-textLink-foreground, #3794ff)",
|
||||
borderRadius: "50%",
|
||||
animation: "spin 1s linear infinite",
|
||||
}}
|
||||
/>
|
||||
<style>
|
||||
{`
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
Loading image from {getSafeHostname(url)}...
|
||||
</div>
|
||||
{elapsedSeconds > 3 && (
|
||||
<div style={{ fontSize: "11px", color: "var(--vscode-descriptionForeground)" }}>
|
||||
{elapsedSeconds > 60
|
||||
? `Waiting for ${Math.floor(elapsedSeconds / 60)}m ${elapsedSeconds % 60}s...`
|
||||
: `Waiting for ${elapsedSeconds}s...`}
|
||||
</div>
|
||||
)}
|
||||
{/* Hidden image that we'll use to detect load/error events */}
|
||||
{/\.svg(\?.*)?$/i.test(url) ? (
|
||||
<object
|
||||
type="image/svg+xml"
|
||||
data={DOMPurify.sanitize(url)}
|
||||
style={{ display: "none" }}
|
||||
onLoad={this.handleImageLoad}
|
||||
onError={this.handleImageError}
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={DOMPurify.sanitize(url)}
|
||||
alt=""
|
||||
ref={this.imgRef}
|
||||
onLoad={this.handleImageLoad}
|
||||
onError={this.handleImageError}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Handle error state
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
className="image-preview-error"
|
||||
style={{
|
||||
padding: "12px",
|
||||
border: "1px solid var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.3))",
|
||||
borderRadius: "4px",
|
||||
color: "var(--vscode-errorForeground)",
|
||||
}}
|
||||
onClick={() => {
|
||||
vscode.postMessage({
|
||||
type: "openInBrowser",
|
||||
url: DOMPurify.sanitize(url),
|
||||
})
|
||||
}}>
|
||||
<div style={{ fontWeight: "bold" }}>Failed to load image</div>
|
||||
<div style={{ fontSize: "12px", marginTop: "4px" }}>{getSafeHostname(url)}</div>
|
||||
<div style={{ fontSize: "11px", marginTop: "8px", color: "var(--vscode-textLink-foreground)" }}>
|
||||
Click to open in browser
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Render the image
|
||||
return (
|
||||
<div
|
||||
className="image-preview"
|
||||
style={{
|
||||
margin: "10px 0",
|
||||
maxWidth: "100%",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => {
|
||||
vscode.postMessage({
|
||||
type: "openInBrowser",
|
||||
url: DOMPurify.sanitize(formatUrlForOpening(url)),
|
||||
})
|
||||
}}>
|
||||
{/\.svg(\?.*)?$/i.test(url) ? (
|
||||
// Special handling for SVG images
|
||||
<object
|
||||
type="image/svg+xml"
|
||||
data={DOMPurify.sanitize(url)}
|
||||
style={{
|
||||
width: "85%",
|
||||
height: "auto",
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
aria-label={`SVG from ${getSafeHostname(url)}`}>
|
||||
{/* Fallback if object tag fails */}
|
||||
<img
|
||||
src={DOMPurify.sanitize(url)}
|
||||
alt={`SVG from ${getSafeHostname(url)}`}
|
||||
style={{
|
||||
width: "85%",
|
||||
height: "auto",
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
/>
|
||||
</object>
|
||||
) : (
|
||||
<img
|
||||
src={DOMPurify.sanitize(url)}
|
||||
alt={`Image from ${getSafeHostname(url)}`}
|
||||
style={{
|
||||
width: "85%",
|
||||
height: "auto",
|
||||
borderRadius: "4px",
|
||||
// Use contain only for very extreme aspect ratios, otherwise use cover
|
||||
objectFit: this.aspectRatio > 3 || this.aspectRatio < 0.33 ? "contain" : "cover",
|
||||
}}
|
||||
loading="eager"
|
||||
onLoad={(e) => {
|
||||
// Double-check aspect ratio from the actual loaded image
|
||||
const img = e.currentTarget
|
||||
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
|
||||
const newAspectRatio = img.naturalWidth / img.naturalHeight
|
||||
|
||||
// Update object-fit based on actual aspect ratio
|
||||
// Use contain only for very extreme aspect ratios, otherwise use cover
|
||||
if (newAspectRatio > 3 || newAspectRatio < 0.33) {
|
||||
img.style.objectFit = "contain"
|
||||
} else {
|
||||
img.style.objectFit = "cover"
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Create a wrapper component that memoizes the ImagePreview to prevent unnecessary re-renders
|
||||
const MemoizedImagePreview = React.memo(
|
||||
(props: ImagePreviewProps) => <ImagePreview {...props} />,
|
||||
(prevProps, nextProps) => prevProps.url === nextProps.url, // Only re-render if URL changes
|
||||
)
|
||||
|
||||
// Wrap the ImagePreview component with an error boundary
|
||||
const ImagePreviewWithErrorBoundary: React.FC<ImagePreviewProps> = (props) => {
|
||||
return (
|
||||
<ChatErrorBoundary errorTitle="Something went wrong displaying this image">
|
||||
<MemoizedImagePreview {...props} />
|
||||
</ChatErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
export default ImagePreviewWithErrorBoundary
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { useEffect, useState } from "react"
|
||||
import { vscode } from "../../utils/vscode"
|
||||
import DOMPurify from "dompurify"
|
||||
import { getSafeHostname, normalizeRelativeUrl } from "./McpRichUtil"
|
||||
import ChatErrorBoundary from "../chat/ChatErrorBoundary"
|
||||
|
||||
interface OpenGraphData {
|
||||
title?: string
|
||||
@@ -15,174 +17,376 @@ interface LinkPreviewProps {
|
||||
url: string
|
||||
}
|
||||
|
||||
const LinkPreview: React.FC<LinkPreviewProps> = ({ url }) => {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [ogData, setOgData] = useState<OpenGraphData | null>(null)
|
||||
// Error types for better UI feedback
|
||||
type ErrorType = "timeout" | "network" | "general" | null
|
||||
|
||||
useEffect(() => {
|
||||
const fetchOpenGraphData = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
// Use a class component to ensure complete isolation between instances
|
||||
class LinkPreview extends React.Component<
|
||||
LinkPreviewProps,
|
||||
{
|
||||
loading: boolean
|
||||
error: ErrorType
|
||||
errorMessage: string | null
|
||||
ogData: OpenGraphData | null
|
||||
hasCompletedFetch: boolean // Track if fetch has completed (success or error)
|
||||
fetchStartTime: number // Track when the fetch started
|
||||
}
|
||||
> {
|
||||
private messageListener: ((event: MessageEvent) => void) | null = null
|
||||
private timeoutId: NodeJS.Timeout | null = null
|
||||
private heartbeatId: NodeJS.Timeout | null = null
|
||||
|
||||
// Send a message to the extension to fetch Open Graph data
|
||||
vscode.postMessage({
|
||||
type: "fetchOpenGraphData",
|
||||
text: url,
|
||||
})
|
||||
constructor(props: LinkPreviewProps) {
|
||||
super(props)
|
||||
this.state = {
|
||||
loading: true,
|
||||
error: null,
|
||||
errorMessage: null,
|
||||
ogData: null,
|
||||
hasCompletedFetch: false,
|
||||
fetchStartTime: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Set up a listener for the response
|
||||
const messageListener = (event: MessageEvent) => {
|
||||
const message = event.data
|
||||
if (message.type === "openGraphData" && message.url === url) {
|
||||
setOgData(message.openGraphData)
|
||||
setLoading(false)
|
||||
window.removeEventListener("message", messageListener)
|
||||
}
|
||||
}
|
||||
componentDidMount() {
|
||||
// Only fetch if we haven't completed a fetch yet
|
||||
if (!this.state.hasCompletedFetch) {
|
||||
this.fetchOpenGraphData()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("message", messageListener)
|
||||
componentWillUnmount() {
|
||||
this.cleanup()
|
||||
}
|
||||
|
||||
// Clean up the listener if the component unmounts
|
||||
return () => {
|
||||
window.removeEventListener("message", messageListener)
|
||||
}
|
||||
} catch (err) {
|
||||
setError("Failed to fetch preview data")
|
||||
setLoading(false)
|
||||
}
|
||||
// Prevent updates if fetch has completed
|
||||
shouldComponentUpdate(nextProps: LinkPreviewProps, nextState: any) {
|
||||
// If URL changes, allow update
|
||||
if (nextProps.url !== this.props.url) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Fetch Open Graph data immediately when component mounts
|
||||
fetchOpenGraphData()
|
||||
}, [url])
|
||||
// If we've completed a fetch and state hasn't changed, prevent update
|
||||
if (
|
||||
this.state.hasCompletedFetch &&
|
||||
this.state.loading === nextState.loading &&
|
||||
this.state.error === nextState.error &&
|
||||
this.state.ogData === nextState.ogData
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Fallback display while loading
|
||||
if (loading) {
|
||||
return true
|
||||
}
|
||||
|
||||
private cleanup() {
|
||||
// Clean up event listeners and timeouts
|
||||
if (this.messageListener) {
|
||||
window.removeEventListener("message", this.messageListener)
|
||||
this.messageListener = null
|
||||
}
|
||||
|
||||
if (this.timeoutId) {
|
||||
clearTimeout(this.timeoutId)
|
||||
this.timeoutId = null
|
||||
}
|
||||
|
||||
if (this.heartbeatId) {
|
||||
clearInterval(this.heartbeatId)
|
||||
this.heartbeatId = null
|
||||
}
|
||||
}
|
||||
|
||||
private fetchOpenGraphData() {
|
||||
try {
|
||||
// Record fetch start time
|
||||
const startTime = Date.now()
|
||||
this.setState({ fetchStartTime: startTime })
|
||||
|
||||
// Send a message to the extension to fetch Open Graph data
|
||||
vscode.postMessage({
|
||||
type: "fetchOpenGraphData",
|
||||
text: this.props.url,
|
||||
})
|
||||
|
||||
// Set up a listener for the response
|
||||
this.messageListener = (event: MessageEvent) => {
|
||||
const message = event.data
|
||||
if (message.type === "openGraphData" && message.url === this.props.url) {
|
||||
// Check if there was an error in the response
|
||||
if (message.error) {
|
||||
this.setState({
|
||||
error: "network",
|
||||
errorMessage: message.error,
|
||||
loading: false,
|
||||
hasCompletedFetch: true,
|
||||
})
|
||||
} else {
|
||||
this.setState({
|
||||
ogData: message.openGraphData,
|
||||
loading: false,
|
||||
hasCompletedFetch: true, // Mark as completed
|
||||
})
|
||||
}
|
||||
this.cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("message", this.messageListener)
|
||||
|
||||
// Instead of a fixed timeout, use a heartbeat to update the loading message
|
||||
// with the elapsed time, but don't actually timeout
|
||||
this.heartbeatId = setInterval(() => {
|
||||
const elapsedSeconds = Math.floor((Date.now() - startTime) / 1000)
|
||||
if (elapsedSeconds > 0) {
|
||||
this.forceUpdate() // Just update the component to show new elapsed time
|
||||
}
|
||||
}, 1000)
|
||||
} catch (err) {
|
||||
this.setState({
|
||||
error: "general",
|
||||
errorMessage: err instanceof Error ? err.message : "Unknown error occurred",
|
||||
loading: false,
|
||||
hasCompletedFetch: true, // Mark as completed on error
|
||||
})
|
||||
this.cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { url } = this.props
|
||||
const { loading, error, errorMessage, ogData, fetchStartTime } = this.state
|
||||
|
||||
// Calculate elapsed time for loading state
|
||||
const elapsedSeconds = loading ? Math.floor((Date.now() - fetchStartTime) / 1000) : 0
|
||||
|
||||
// Fallback display while loading
|
||||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
className="link-preview-loading"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
border: "1px solid var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.3))",
|
||||
borderRadius: "4px",
|
||||
height: "128px",
|
||||
maxWidth: "512px",
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "center", marginBottom: "8px" }}>
|
||||
<div
|
||||
className="loading-spinner"
|
||||
style={{
|
||||
marginRight: "8px",
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
border: "2px solid rgba(127, 127, 127, 0.3)",
|
||||
borderTopColor: "var(--vscode-textLink-foreground, #3794ff)",
|
||||
borderRadius: "50%",
|
||||
animation: "spin 1s linear infinite",
|
||||
}}
|
||||
/>
|
||||
<style>
|
||||
{`
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
Loading preview for {getSafeHostname(url)}...
|
||||
</div>
|
||||
{elapsedSeconds > 5 && (
|
||||
<div style={{ fontSize: "11px", color: "var(--vscode-descriptionForeground)" }}>
|
||||
{elapsedSeconds > 60
|
||||
? `Waiting for ${Math.floor(elapsedSeconds / 60)}m ${elapsedSeconds % 60}s...`
|
||||
: `Waiting for ${elapsedSeconds}s...`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Handle different error states with specific messages
|
||||
if (error) {
|
||||
let errorDisplay = "Unable to load preview"
|
||||
|
||||
if (error === "timeout") {
|
||||
errorDisplay = "Preview request timed out"
|
||||
} else if (error === "network") {
|
||||
errorDisplay = "Network error loading preview"
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="link-preview-error"
|
||||
style={{
|
||||
padding: "12px",
|
||||
border: "1px solid var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.3))",
|
||||
borderRadius: "4px",
|
||||
color: "var(--vscode-errorForeground)",
|
||||
height: "128px",
|
||||
maxWidth: "512px",
|
||||
overflow: "auto",
|
||||
}}
|
||||
onClick={() => {
|
||||
vscode.postMessage({
|
||||
type: "openInBrowser",
|
||||
url: DOMPurify.sanitize(url),
|
||||
})
|
||||
}}>
|
||||
<div style={{ fontWeight: "bold" }}>{errorDisplay}</div>
|
||||
<div style={{ fontSize: "12px", marginTop: "4px" }}>{getSafeHostname(url)}</div>
|
||||
{errorMessage && <div style={{ fontSize: "11px", marginTop: "4px", opacity: 0.8 }}>{errorMessage}</div>}
|
||||
<div style={{ fontSize: "11px", marginTop: "8px", color: "var(--vscode-textLink-foreground)" }}>
|
||||
Click to open in browser
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Create a fallback object if ogData is null
|
||||
const data = ogData || {
|
||||
title: getSafeHostname(url),
|
||||
description: "No description available",
|
||||
siteName: getSafeHostname(url),
|
||||
url: url,
|
||||
}
|
||||
|
||||
// Render the Open Graph preview
|
||||
return (
|
||||
<div
|
||||
className="link-preview-loading"
|
||||
className="link-preview"
|
||||
style={{
|
||||
padding: "12px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
border: "1px solid var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.3))",
|
||||
borderRadius: "4px",
|
||||
overflow: "hidden",
|
||||
cursor: "pointer",
|
||||
height: "128px",
|
||||
maxWidth: "512px",
|
||||
}}
|
||||
onClick={() => {
|
||||
vscode.postMessage({
|
||||
type: "openInBrowser",
|
||||
url: DOMPurify.sanitize(url),
|
||||
})
|
||||
}}>
|
||||
{data.image && (
|
||||
<div className="link-preview-image" style={{ width: "128px", height: "128px", flexShrink: 0 }}>
|
||||
<img
|
||||
src={DOMPurify.sanitize(normalizeRelativeUrl(data.image, url))}
|
||||
alt=""
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "contain", // Use contain for link preview thumbnails to handle logos
|
||||
objectPosition: "center", // Center the image
|
||||
}}
|
||||
onLoad={(e) => {
|
||||
// Check aspect ratio to determine if we should use contain or cover
|
||||
const img = e.currentTarget
|
||||
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
|
||||
const aspectRatio = img.naturalWidth / img.naturalHeight
|
||||
|
||||
// Use contain for extreme aspect ratios (logos), cover for photos
|
||||
if (aspectRatio > 2.5 || aspectRatio < 0.4) {
|
||||
img.style.objectFit = "contain"
|
||||
} else {
|
||||
img.style.objectFit = "cover"
|
||||
}
|
||||
}
|
||||
}}
|
||||
onError={(e) => {
|
||||
console.log(`Image could not be loaded: ${data.image}`)
|
||||
// Hide the broken image
|
||||
;(e.target as HTMLImageElement).style.display = "none"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="loading-spinner"
|
||||
className="link-preview-content"
|
||||
style={{
|
||||
marginRight: "8px",
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
border: "2px solid rgba(127, 127, 127, 0.3)",
|
||||
borderTopColor: "var(--vscode-textLink-foreground, #3794ff)",
|
||||
borderRadius: "50%",
|
||||
animation: "spin 1s linear infinite",
|
||||
}}
|
||||
/>
|
||||
<style>
|
||||
{`
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
Loading preview for {new URL(url).hostname}...
|
||||
flex: 1,
|
||||
padding: "12px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
height: "100%", // Ensure full height
|
||||
}}>
|
||||
{/* Top section with title and URL - top aligned */}
|
||||
<div className="link-preview-top">
|
||||
<div
|
||||
className="link-preview-title"
|
||||
style={{
|
||||
fontWeight: "bold",
|
||||
marginBottom: "4px",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}>
|
||||
{data.title || "No title"}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="link-preview-url"
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "var(--vscode-textLink-foreground, #3794ff)",
|
||||
marginBottom: "8px", // Increased for better separation
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}>
|
||||
{data.siteName || getSafeHostname(url)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description with space-around in the remaining space */}
|
||||
<div
|
||||
className="link-preview-description-container"
|
||||
style={{
|
||||
flex: 1, // Take up remaining space
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "space-around", // Space around in the remaining area
|
||||
}}>
|
||||
<div
|
||||
className="link-preview-description"
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "var(--vscode-descriptionForeground, rgba(204, 204, 204, 0.7))",
|
||||
overflow: "hidden",
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: "vertical",
|
||||
textOverflow: "ellipsis",
|
||||
}}>
|
||||
{data.description || "No description available"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Create a fallback object if ogData is null
|
||||
const data = ogData || {
|
||||
title: new URL(url).hostname,
|
||||
description: "No description available",
|
||||
siteName: new URL(url).hostname,
|
||||
url: url,
|
||||
}
|
||||
// Create a wrapper component that memoizes the LinkPreview to prevent unnecessary re-renders
|
||||
const MemoizedLinkPreview = React.memo(
|
||||
(props: LinkPreviewProps) => <LinkPreview {...props} />,
|
||||
(prevProps, nextProps) => prevProps.url === nextProps.url, // Only re-render if URL changes
|
||||
)
|
||||
|
||||
// Render the Open Graph preview
|
||||
// Wrap the LinkPreview component with an error boundary
|
||||
const LinkPreviewWithErrorBoundary: React.FC<LinkPreviewProps> = (props) => {
|
||||
return (
|
||||
<div
|
||||
className="link-preview"
|
||||
style={{
|
||||
display: "flex",
|
||||
border: "1px solid var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.3))",
|
||||
borderRadius: "4px",
|
||||
overflow: "hidden",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => {
|
||||
vscode.postMessage({
|
||||
type: "openInBrowser",
|
||||
url: DOMPurify.sanitize(url),
|
||||
})
|
||||
}}>
|
||||
{data.image && (
|
||||
<div className="link-preview-image" style={{ width: "128px", height: "128px", flexShrink: 0 }}>
|
||||
<img
|
||||
src={DOMPurify.sanitize(data.image)}
|
||||
alt=""
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="link-preview-content"
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "12px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
}}>
|
||||
<div
|
||||
className="link-preview-title"
|
||||
style={{
|
||||
fontWeight: "bold",
|
||||
marginBottom: "4px",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}>
|
||||
{data.title || "No title"}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="link-preview-url"
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "var(--vscode-textLink-foreground, #3794ff)",
|
||||
marginBottom: "8px",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}>
|
||||
{data.siteName || new URL(url).hostname}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="link-preview-description"
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "var(--vscode-descriptionForeground, rgba(204, 204, 204, 0.7))",
|
||||
overflow: "hidden",
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: "vertical",
|
||||
textOverflow: "ellipsis",
|
||||
}}>
|
||||
{data.description || "No description available"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ChatErrorBoundary errorTitle="Something went wrong displaying this link preview">
|
||||
<MemoizedLinkPreview {...props} />
|
||||
</ChatErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
export default LinkPreview
|
||||
export default LinkPreviewWithErrorBoundary
|
||||
|
||||
@@ -1,180 +1,23 @@
|
||||
import React, { useEffect, useState, useCallback } from "react"
|
||||
import { vscode } from "../../utils/vscode"
|
||||
import LinkPreview from "./LinkPreview"
|
||||
import ImagePreview from "./ImagePreview"
|
||||
import { vscode } from "../../utils/vscode"
|
||||
import DOMPurify from "dompurify"
|
||||
import styled from "styled-components"
|
||||
import { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock"
|
||||
import DOMPurify from "dompurify"
|
||||
import ChatErrorBoundary from "../chat/ChatErrorBoundary"
|
||||
import {
|
||||
safeCreateUrl,
|
||||
isUrl,
|
||||
getSafeHostname,
|
||||
isLocalhostUrl,
|
||||
normalizeRelativeUrl,
|
||||
formatUrlForOpening,
|
||||
checkIfImageUrl,
|
||||
} from "./McpRichUtil"
|
||||
|
||||
// We'll use the backend isImageUrl function for HEAD requests
|
||||
// This is a client-side fallback for data URLs and obvious image extensions
|
||||
const isImageUrlSync = (str: string): boolean => {
|
||||
// Check for data URLs which are definitely images
|
||||
if (str.startsWith("data:image/")) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for common image file extensions
|
||||
return str.match(/\.(jpg|jpeg|png|gif|webp)$/i) !== null
|
||||
}
|
||||
|
||||
export const isUrl = (str: string): boolean => {
|
||||
// Basic URL validation
|
||||
const urlPattern = /^(https?:\/\/)?([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}(\/[^\s]*)?$/
|
||||
return urlPattern.test(str)
|
||||
}
|
||||
|
||||
// Function to check if a URL is an image using HEAD request
|
||||
export const checkIfImageUrl = async (url: string): Promise<boolean> => {
|
||||
// For data URLs, we can check synchronously
|
||||
if (url.startsWith("data:image/")) {
|
||||
return true
|
||||
}
|
||||
|
||||
// For http/https URLs, we need to send a message to the extension
|
||||
if (url.startsWith("http")) {
|
||||
try {
|
||||
// Create a promise that will resolve when we get a response
|
||||
return new Promise((resolve) => {
|
||||
// Set up a one-time listener for the response
|
||||
const messageListener = (event: MessageEvent) => {
|
||||
const message = event.data
|
||||
if (message.type === "isImageUrlResult" && message.url === url) {
|
||||
window.removeEventListener("message", messageListener)
|
||||
resolve(message.isImage)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("message", messageListener)
|
||||
|
||||
// Send the request to the extension
|
||||
vscode.postMessage({
|
||||
type: "checkIsImageUrl",
|
||||
text: url,
|
||||
})
|
||||
|
||||
// Set a timeout to avoid hanging indefinitely
|
||||
setTimeout(() => {
|
||||
window.removeEventListener("message", messageListener)
|
||||
// Fall back to extension check
|
||||
resolve(isImageUrlSync(url))
|
||||
}, 3000)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Error checking if URL is an image:", error)
|
||||
return isImageUrlSync(url)
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to extension check for other URLs
|
||||
return isImageUrlSync(url)
|
||||
}
|
||||
|
||||
// No longer needed as our regex directly extracts the URL part
|
||||
|
||||
// Helper to ensure URL is in a format that can be opened
|
||||
export const formatUrlForOpening = (url: string): string => {
|
||||
// If it's a data URI, return as is
|
||||
if (url.startsWith("data:image/")) {
|
||||
return url
|
||||
}
|
||||
|
||||
// If it's a regular URL but doesn't have a protocol, add https://
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||
return `https://${url}`
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
// Find all URLs (both image and regular) in an object
|
||||
export const findUrls = async (obj: any): Promise<{ imageUrls: string[]; regularUrls: string[] }> => {
|
||||
const imageUrls: string[] = []
|
||||
const regularUrls: string[] = []
|
||||
const pendingChecks: Promise<void>[] = []
|
||||
|
||||
if (typeof obj === "object" && obj !== null) {
|
||||
for (const value of Object.values(obj)) {
|
||||
if (typeof value === "string") {
|
||||
// First check with synchronous method
|
||||
if (isImageUrlSync(value)) {
|
||||
imageUrls.push(value)
|
||||
} else if (isUrl(value)) {
|
||||
// For URLs that don't obviously look like images, we'll check asynchronously
|
||||
const checkPromise = checkIfImageUrl(value).then((isImage) => {
|
||||
if (isImage) {
|
||||
imageUrls.push(value)
|
||||
} else {
|
||||
regularUrls.push(value)
|
||||
}
|
||||
})
|
||||
pendingChecks.push(checkPromise)
|
||||
}
|
||||
} else if (typeof value === "object") {
|
||||
const nestedUrlsPromise = findUrls(value).then((nestedUrls) => {
|
||||
imageUrls.push(...nestedUrls.imageUrls)
|
||||
regularUrls.push(...nestedUrls.regularUrls)
|
||||
})
|
||||
pendingChecks.push(nestedUrlsPromise)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for all async checks to complete
|
||||
await Promise.all(pendingChecks)
|
||||
|
||||
return { imageUrls, regularUrls }
|
||||
}
|
||||
|
||||
// Extract URLs from text using regex
|
||||
export const extractUrlsFromText = async (text: string): Promise<{ imageUrls: string[]; regularUrls: string[] }> => {
|
||||
const imageUrls: string[] = []
|
||||
const regularUrls: string[] = []
|
||||
const pendingChecks: Promise<void>[] = []
|
||||
|
||||
// Match URLs with image: prefix and extract just the URL part
|
||||
const imageMatches = text.match(/image:\s*(https?:\/\/[^\s]+)/g)
|
||||
if (imageMatches) {
|
||||
// Extract just the URL part from matches with image: prefix
|
||||
const extractedUrls = imageMatches
|
||||
.map((match) => {
|
||||
const urlMatch = /image:\s*(https?:\/\/[^\s]+)/.exec(match)
|
||||
return urlMatch ? urlMatch[1] : null
|
||||
})
|
||||
.filter(Boolean) as string[]
|
||||
|
||||
imageUrls.push(...extractedUrls)
|
||||
}
|
||||
|
||||
// Match all URLs (including those that might be in the middle of paragraphs)
|
||||
const urlMatches = text.match(/https?:\/\/[^\s]+/g)
|
||||
if (urlMatches) {
|
||||
// Filter out URLs that are already in imageUrls
|
||||
const filteredUrls = urlMatches.filter((url) => !imageUrls.includes(url))
|
||||
|
||||
// Check each URL to see if it's an image
|
||||
for (const url of filteredUrls) {
|
||||
// First check with synchronous method
|
||||
if (isImageUrlSync(url)) {
|
||||
imageUrls.push(url)
|
||||
} else {
|
||||
// For URLs that don't obviously look like images, we'll check asynchronously
|
||||
const checkPromise = checkIfImageUrl(url).then((isImage) => {
|
||||
if (isImage) {
|
||||
imageUrls.push(url)
|
||||
} else {
|
||||
regularUrls.push(url)
|
||||
}
|
||||
})
|
||||
pendingChecks.push(checkPromise)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for all async checks to complete
|
||||
await Promise.all(pendingChecks)
|
||||
|
||||
return { imageUrls, regularUrls }
|
||||
}
|
||||
// Maximum number of URLs to process in total, per response
|
||||
export const MAX_URLS = 50
|
||||
|
||||
const ResponseHeader = styled.div`
|
||||
display: flex;
|
||||
@@ -271,7 +114,7 @@ interface McpResponseDisplayProps {
|
||||
// Represents a URL found in the text with its position and metadata
|
||||
interface UrlMatch {
|
||||
url: string // The actual URL
|
||||
fullMatch: string // The full matched text (including any prefix like "image:")
|
||||
fullMatch: string // The full matched text
|
||||
index: number // Position in the text
|
||||
isImage: boolean // Whether this URL is an image
|
||||
isProcessed: boolean // Whether we've already processed this URL (to avoid duplicates)
|
||||
@@ -282,59 +125,159 @@ const McpResponseDisplay: React.FC<McpResponseDisplayProps> = ({ responseText })
|
||||
const [displayMode, setDisplayMode] = useState<"rich" | "plain">(() => {
|
||||
// Get saved preference from localStorage, default to 'rich'
|
||||
const savedMode = localStorage.getItem("mcpDisplayMode")
|
||||
return (savedMode === "plain" ? "plain" : "rich") as "rich" | "plain"
|
||||
return savedMode === "plain" ? "plain" : "rich"
|
||||
})
|
||||
const [urlMatches, setUrlMatches] = useState<UrlMatch[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
// Add a counter state for forcing re-renders to make toggling run smoother
|
||||
const [forceUpdateCounter, setForceUpdateCounter] = useState(0)
|
||||
|
||||
const toggleDisplayMode = useCallback(() => {
|
||||
const newMode = displayMode === "rich" ? "plain" : "rich"
|
||||
|
||||
// Force an immediate re-render
|
||||
setForceUpdateCounter((prev) => prev + 1)
|
||||
|
||||
// Update display mode and save preference
|
||||
setDisplayMode(newMode)
|
||||
localStorage.setItem("mcpDisplayMode", newMode)
|
||||
|
||||
// If switching to plain mode, cancel any ongoing processing
|
||||
if (newMode === "plain") {
|
||||
console.log("Switching to plain mode - cancelling URL processing")
|
||||
setUrlMatches([]) // Clear any existing matches when switching to plain mode
|
||||
} else {
|
||||
// If switching to rich mode, the useEffect will re-run and fetch data
|
||||
console.log("Switching to rich mode - will start URL processing")
|
||||
}
|
||||
}, [displayMode])
|
||||
|
||||
// Find all URLs in the text and determine if they're images
|
||||
useEffect(() => {
|
||||
// Skip all processing if in plain mode
|
||||
if (displayMode === "plain") {
|
||||
setIsLoading(false)
|
||||
setUrlMatches([]) // Clear any existing matches when in plain mode
|
||||
return
|
||||
}
|
||||
|
||||
// Use a direct boolean for cancellation that's scoped to this effect run
|
||||
let processingCanceled = false
|
||||
|
||||
const processResponse = async () => {
|
||||
console.log("Processing MCP response for URL extraction")
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const text = responseText || ""
|
||||
const matches: UrlMatch[] = []
|
||||
|
||||
const urlRegex = /https?:\/\/[^\s]+/g
|
||||
const urlRegex = /https?:\/\/[^\s<>"']+/g
|
||||
let urlMatch: RegExpExecArray | null
|
||||
let urlCount = 0
|
||||
|
||||
while ((urlMatch = urlRegex.exec(text)) !== null) {
|
||||
// First pass: Extract all URLs and immediately make them available for rendering
|
||||
while ((urlMatch = urlRegex.exec(text)) !== null && urlCount < MAX_URLS) {
|
||||
// Get the original URL from the match - never modify the original URL text
|
||||
const url = urlMatch[0]
|
||||
const fullMatch = url
|
||||
|
||||
// Skip invalid URLs
|
||||
if (!isUrl(url)) {
|
||||
console.log("Skipping invalid URL:", url)
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip localhost URLs to prevent security issues
|
||||
if (isLocalhostUrl(url)) {
|
||||
console.log("Skipping localhost URL:", url)
|
||||
continue
|
||||
}
|
||||
|
||||
matches.push({
|
||||
url,
|
||||
fullMatch,
|
||||
fullMatch: url,
|
||||
index: urlMatch.index,
|
||||
isImage: false, // Will check later
|
||||
isProcessed: false,
|
||||
})
|
||||
|
||||
urlCount++
|
||||
}
|
||||
|
||||
// Check if URLs are images
|
||||
for (const match of matches) {
|
||||
match.isImage = await checkIfImageUrl(match.url)
|
||||
console.log(`Found ${matches.length} URLs in text, will check if they are images`)
|
||||
|
||||
// Set matches immediately so UI can start rendering with loading states
|
||||
setUrlMatches(matches.sort((a, b) => a.index - b.index))
|
||||
|
||||
// Mark loading as complete to show content immediately
|
||||
setIsLoading(false)
|
||||
|
||||
// Process image checks in the background - one at a time to avoid network flooding
|
||||
const processImageChecks = async () => {
|
||||
console.log(`Starting sequential URL processing for ${matches.length} URLs`)
|
||||
|
||||
for (let i = 0; i < matches.length; i++) {
|
||||
// Skip already processed URLs (from extension check)
|
||||
if (matches[i].isProcessed) continue
|
||||
|
||||
// Check if processing has been canceled (switched to plain mode)
|
||||
if (processingCanceled) {
|
||||
console.log("URL processing canceled - display mode changed to plain")
|
||||
return
|
||||
}
|
||||
|
||||
const match = matches[i]
|
||||
console.log(`Processing URL ${i + 1} of ${matches.length}: ${match.url}`)
|
||||
|
||||
try {
|
||||
// Process each URL individually
|
||||
const isImage = await checkIfImageUrl(match.url)
|
||||
|
||||
// Skip if processing has been canceled
|
||||
if (processingCanceled) return
|
||||
|
||||
// Update the match in place
|
||||
match.isImage = isImage
|
||||
match.isProcessed = true
|
||||
|
||||
// Update state after each URL to show progress
|
||||
// Create a new array to ensure React detects the state change
|
||||
setUrlMatches([...matches])
|
||||
} catch (err) {
|
||||
console.log(`URL check error: ${match.url}`, err)
|
||||
match.isProcessed = true
|
||||
|
||||
// Update state even on error
|
||||
if (!processingCanceled) {
|
||||
setUrlMatches([...matches])
|
||||
}
|
||||
}
|
||||
|
||||
// Delay between URL processing to avoid overwhelming the network
|
||||
if (!processingCanceled && i < matches.length - 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`URL processing complete. Found ${matches.filter((m) => m.isImage).length} image URLs`)
|
||||
}
|
||||
|
||||
// Sort by position in the text
|
||||
matches.sort((a, b) => a.index - b.index)
|
||||
|
||||
setUrlMatches(matches)
|
||||
// Start the background processing
|
||||
processImageChecks()
|
||||
} catch (error) {
|
||||
console.error("Error processing MCP response:", error)
|
||||
} finally {
|
||||
setError("Failed to process response content. Switch to plain text mode to view safely.")
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
processResponse()
|
||||
}, [responseText])
|
||||
|
||||
// Cleanup function to cancel processing if component unmounts or dependencies change
|
||||
return () => {
|
||||
processingCanceled = true
|
||||
console.log("Cleaning up URL processing")
|
||||
}
|
||||
}, [responseText, displayMode, forceUpdateCounter])
|
||||
|
||||
// Function to render content based on display mode
|
||||
const renderContent = () => {
|
||||
@@ -343,15 +286,26 @@ const McpResponseDisplay: React.FC<McpResponseDisplayProps> = ({ responseText })
|
||||
return <UrlText>{responseText}</UrlText>
|
||||
}
|
||||
|
||||
// Show error message if there was an error
|
||||
if (error) {
|
||||
return (
|
||||
<>
|
||||
<div style={{ color: "var(--vscode-errorForeground)", marginBottom: "10px" }}>{error}</div>
|
||||
<UrlText>{responseText}</UrlText>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// For rich display mode, show the text with embedded content
|
||||
if (displayMode === "rich" && !isLoading) {
|
||||
if (!isLoading) {
|
||||
// We already know displayMode is "rich" if we get here
|
||||
// Create an array of text segments and embedded content
|
||||
const segments: JSX.Element[] = []
|
||||
let lastIndex = 0
|
||||
let segmentIndex = 0
|
||||
|
||||
// Reset the processed flag for all URLs
|
||||
const processedUrls = new Set<string>()
|
||||
// Track embed count for logging
|
||||
let embedCount = 0
|
||||
|
||||
// Add the text before the first URL
|
||||
if (urlMatches.length === 0) {
|
||||
@@ -375,38 +329,51 @@ const McpResponseDisplay: React.FC<McpResponseDisplayProps> = ({ responseText })
|
||||
const urlEndIndex = index + fullMatch.length
|
||||
|
||||
// Add embedded content after the URL
|
||||
// For images, use the ImagePreview component
|
||||
if (match.isImage) {
|
||||
segments.push(
|
||||
<div key={`embed-${segmentIndex++}`} style={{ margin: "10px 0" }}>
|
||||
<img
|
||||
src={DOMPurify.sanitize(url)}
|
||||
alt={`Image for ${url}`}
|
||||
style={{
|
||||
width: "85%",
|
||||
height: "auto",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => {
|
||||
const formattedUrl = formatUrlForOpening(url)
|
||||
vscode.postMessage({
|
||||
type: "openInBrowser",
|
||||
url: DOMPurify.sanitize(formattedUrl),
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>,
|
||||
)
|
||||
} else if (!processedUrls.has(url)) {
|
||||
// For non-image URLs, only show the preview once
|
||||
segments.push(
|
||||
<div key={`embed-${segmentIndex++}`} style={{ margin: "10px 0" }}>
|
||||
<LinkPreview url={formatUrlForOpening(url)} />
|
||||
<div key={`embed-image-${url}-${segmentIndex++}`}>
|
||||
{/* Use formatUrlForOpening for network calls but preserve original URL in display */}
|
||||
<ImagePreview url={formatUrlForOpening(url)} />
|
||||
</div>,
|
||||
)
|
||||
embedCount++
|
||||
// console.log(`Added image embed for ${url}, embed count: ${embedCount}`);
|
||||
} else if (match.isProcessed) {
|
||||
// For non-image URLs or URLs we haven't processed yet, show link preview
|
||||
try {
|
||||
// Skip localhost URLs
|
||||
if (!isLocalhostUrl(url)) {
|
||||
// Use a unique key that includes the URL to ensure each preview is isolated
|
||||
segments.push(
|
||||
<div key={`embed-${url}-${segmentIndex++}`} style={{ margin: "10px 0" }}>
|
||||
{/* Already using formatUrlForOpening for link previews */}
|
||||
<LinkPreview url={formatUrlForOpening(url)} />
|
||||
</div>,
|
||||
)
|
||||
|
||||
// Mark this URL as processed
|
||||
processedUrls.add(url)
|
||||
embedCount++
|
||||
// console.log(`Added link preview for ${url}, embed count: ${embedCount}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("Link preview could not be created")
|
||||
// Show error message for failed link preview
|
||||
segments.push(
|
||||
<div
|
||||
key={`embed-error-${segmentIndex++}`}
|
||||
style={{
|
||||
margin: "10px 0",
|
||||
padding: "8px",
|
||||
color: "var(--vscode-errorForeground)",
|
||||
border: "1px solid var(--vscode-editorError-foreground)",
|
||||
borderRadius: "4px",
|
||||
height: "128px", // Fixed height
|
||||
overflow: "auto", // Allow scrolling if content overflows
|
||||
}}>
|
||||
Failed to create preview for: {url}
|
||||
</div>,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Update lastIndex for next segment
|
||||
@@ -442,7 +409,7 @@ const McpResponseDisplay: React.FC<McpResponseDisplayProps> = ({ responseText })
|
||||
</ResponseContainer>
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("Error parsing MCP response:", error)
|
||||
console.log("Error rendering MCP response - falling back to plain text")
|
||||
return (
|
||||
<ResponseContainer>
|
||||
<ResponseHeader>
|
||||
@@ -457,4 +424,13 @@ const McpResponseDisplay: React.FC<McpResponseDisplayProps> = ({ responseText })
|
||||
}
|
||||
}
|
||||
|
||||
export default McpResponseDisplay
|
||||
// Wrap the entire McpResponseDisplay component with an error boundary
|
||||
const McpResponseDisplayWithErrorBoundary: React.FC<McpResponseDisplayProps> = (props) => {
|
||||
return (
|
||||
<ChatErrorBoundary>
|
||||
<McpResponseDisplay {...props} />
|
||||
</ChatErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
export default McpResponseDisplayWithErrorBoundary
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { vscode } from "../../utils/vscode"
|
||||
|
||||
// Safely create a URL object with error handling and ensure HTTPS
|
||||
export const safeCreateUrl = (url: string): URL | null => {
|
||||
try {
|
||||
// Convert HTTP to HTTPS for security
|
||||
if (url.startsWith("http://")) {
|
||||
url = url.replace("http://", "https://")
|
||||
}
|
||||
|
||||
return new URL(url)
|
||||
} catch (e) {
|
||||
// If the URL doesn't have a protocol, add https://
|
||||
if (!url.startsWith("https://")) {
|
||||
try {
|
||||
return new URL(`https://${url}`)
|
||||
} catch (e) {
|
||||
console.log(`Invalid URL: ${url}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
console.log(`Invalid URL: ${url}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Check if a string is a valid URL
|
||||
export const isUrl = (str: string): boolean => {
|
||||
return safeCreateUrl(str) !== null
|
||||
}
|
||||
|
||||
// Get hostname safely
|
||||
export const getSafeHostname = (url: string): string => {
|
||||
try {
|
||||
const urlObj = safeCreateUrl(url)
|
||||
return urlObj ? urlObj.hostname : "unknown-host"
|
||||
} catch (e) {
|
||||
return "unknown-host"
|
||||
}
|
||||
}
|
||||
|
||||
// Check if a URL is a localhost URL by examining the hostname
|
||||
export const isLocalhostUrl = (url: string): boolean => {
|
||||
try {
|
||||
const hostname = getSafeHostname(url)
|
||||
return (
|
||||
hostname === "localhost" ||
|
||||
hostname === "127.0.0.1" ||
|
||||
hostname === "0.0.0.0" ||
|
||||
hostname.startsWith("192.168.") ||
|
||||
hostname.startsWith("10.") ||
|
||||
hostname.endsWith(".local")
|
||||
)
|
||||
} catch (e) {
|
||||
// If we can't parse the URL, assume it's not localhost
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Function to normalize relative URLs by combining with a base URL
|
||||
export const normalizeRelativeUrl = (relativeUrl: string, baseUrl: string): string => {
|
||||
// If it's already an absolute URL or a data URL, return as is
|
||||
if (relativeUrl.startsWith("http://") || relativeUrl.startsWith("https://") || relativeUrl.startsWith("data:")) {
|
||||
return relativeUrl
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse the base URL
|
||||
const baseUrlObj = safeCreateUrl(baseUrl)
|
||||
if (!baseUrlObj) {
|
||||
return relativeUrl // If we can't parse the base URL, return original
|
||||
}
|
||||
|
||||
// Handle different types of relative paths
|
||||
if (relativeUrl.startsWith("//")) {
|
||||
// Protocol-relative URL
|
||||
return `${baseUrlObj.protocol}${relativeUrl}`
|
||||
} else if (relativeUrl.startsWith("/")) {
|
||||
// Root-relative URL
|
||||
return `${baseUrlObj.protocol}//${baseUrlObj.host}${relativeUrl}`
|
||||
} else {
|
||||
// Path-relative URL
|
||||
// Get the directory part of the URL
|
||||
let basePath = baseUrlObj.pathname
|
||||
if (!basePath.endsWith("/")) {
|
||||
// If the path doesn't end with a slash, remove the file part
|
||||
basePath = basePath.substring(0, basePath.lastIndexOf("/") + 1)
|
||||
}
|
||||
return `${baseUrlObj.protocol}//${baseUrlObj.host}${basePath}${relativeUrl}`
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`Error normalizing relative URL: ${error}`)
|
||||
return relativeUrl // Return original on error
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to ensure URL is in a format that can be opened
|
||||
export const formatUrlForOpening = (url: string): string => {
|
||||
// If it's a data URI, return as is
|
||||
if (url.startsWith("data:image/")) {
|
||||
return url
|
||||
}
|
||||
|
||||
// Use safeCreateUrl to validate and format the URL
|
||||
const urlObj = safeCreateUrl(url)
|
||||
if (urlObj) {
|
||||
return urlObj.href
|
||||
}
|
||||
|
||||
console.log(`Invalid URL format: ${url}`)
|
||||
// Return a safe fallback that won't crash
|
||||
return "about:blank"
|
||||
}
|
||||
|
||||
// Function to check if a URL is an image using HEAD request
|
||||
export const checkIfImageUrl = async (url: string): Promise<boolean> => {
|
||||
// For data URLs, we can check synchronously
|
||||
if (url.startsWith("data:image/")) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Create a secure URL for the check but don't modify the original URL
|
||||
let secureUrl = url
|
||||
// Convert HTTP to HTTPS for security in the network request only
|
||||
if (secureUrl.startsWith("http://")) {
|
||||
secureUrl = secureUrl.replace("http://", "https://")
|
||||
console.log(`Using HTTPS version for image check: ${secureUrl}`)
|
||||
}
|
||||
|
||||
// Validate URL before proceeding
|
||||
if (!isUrl(url)) {
|
||||
console.log("Invalid URL format:", url)
|
||||
return false
|
||||
}
|
||||
|
||||
// For https URLs, we need to send a message to the extension
|
||||
if (url.startsWith("https")) {
|
||||
try {
|
||||
// Create a promise that will resolve when we get a response
|
||||
return new Promise((resolve) => {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined = undefined
|
||||
|
||||
// Set up a one-time listener for the response
|
||||
const messageListener = (event: MessageEvent) => {
|
||||
const message = event.data
|
||||
if (message.type === "isImageUrlResult" && message.url === url) {
|
||||
window.removeEventListener("message", messageListener)
|
||||
resolve(message.isImage)
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("message", messageListener)
|
||||
|
||||
// Send the request to the extension
|
||||
vscode.postMessage({
|
||||
type: "checkIsImageUrl",
|
||||
text: url,
|
||||
})
|
||||
|
||||
// Set a timeout to avoid hanging indefinitely
|
||||
timeoutId = setTimeout(() => {
|
||||
window.removeEventListener("message", messageListener)
|
||||
console.log("Hit timeout waiting for checkIsImageUrl")
|
||||
resolve(false)
|
||||
}, 3000)
|
||||
})
|
||||
} catch (error) {
|
||||
console.log("Error checking if URL is an image:", url)
|
||||
// Don't fall back to extension check on error
|
||||
// Instead, return false to indicate it's not an image
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Don't fall back to extension check for other URLs
|
||||
// Only data URLs (handled above) are guaranteed to be images
|
||||
// For all other URLs, we need proper content type verification
|
||||
console.log(`URL protocol not supported for image check: ${url}`)
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,683 @@
|
||||
# How To Test Rich MCP Responses
|
||||
|
||||
Use the `echo` MCP server to read back one of the test cases below into an MCP response.
|
||||
https://github.com/Garoth/echo-mcp
|
||||
|
||||
Manually check the embeds, images, and whatever other enhancements for proper rendering.
|
||||
Remember that toggling Rich MCP off should cancel pending fetches. If the toggle was
|
||||
set to Plain, then the image/link previews should never be fetched until it's enabled.
|
||||
Remember that rich display mode will only load the first n URLs, currently set to 50
|
||||
|
||||
## Main Test Case
|
||||
|
||||
Working Image URLs
|
||||
|
||||
jpg: https://yavuzceliker.github.io/sample-images/image-205.jpg
|
||||
webp: https://seenandheard.app/assets/img/face-2.webp
|
||||
svg: https://seenandheard.app/assets/img/logo-white.svg
|
||||
|
||||
Looks like Image URL but is website
|
||||
|
||||
site: https://github.com/google/pprof/blob/main/doc/images/webui/flame-multi.png
|
||||
raw png: https://raw.githubusercontent.com/google/pprof/refs/heads/main/doc/images/webui/flame-multi.png
|
||||
|
||||
Gif:
|
||||
|
||||
https://upload.wikimedia.org/wikipedia/commons/thumb/d/d0/01_Das_Sandberg-Modell.gif/750px-01_Das_Sandberg-Modell.gif
|
||||
|
||||
Normal Working URLs for OG Embeds
|
||||
|
||||
https://www.google.com
|
||||
https://www.blogger.com
|
||||
https://youtube.com
|
||||
https://linkedin.com
|
||||
https://support.google.com
|
||||
https://cloudflare.com
|
||||
https://microsoft.com
|
||||
https://apple.com
|
||||
https://en.wikipedia.org
|
||||
https://play.google.com
|
||||
https://wordpress.org
|
||||
|
||||
Attack URLs & Unsupported Formats
|
||||
|
||||
data:text/html,<h1>Hello World</h1>
|
||||
data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==
|
||||
javascript:alert('XSS')
|
||||
mailto:user@example.com
|
||||
tel:+1-234-567-8901
|
||||
sms:+1-234-567-8901?body=Hello
|
||||
https://www.example.com/path/to/file.html?param=<script>alert('XSS')</script>
|
||||
https://www.example.com/path/to/file.html?param=<img src="x" onerror="alert('XSS')">
|
||||
https://www.example.com/path/to/file.html?param=javascript:alert('XSS')
|
||||
https://www.example.com/path/to/file.html?param=data:text/html,<script>alert('XSS')</script>
|
||||
https://www.example.com/path/to/file.html?param=data:image/svg+xml,<svg onload="alert('XSS')">
|
||||
https://www.example.com/path/to/file.html?param=<iframe src="javascript:alert('XSS')">
|
||||
https://www.example.com/path/to/file.html?param=<a href="javascript:alert('XSS')">Click me</a>
|
||||
|
||||
Broken & Weird Edge Cases
|
||||
|
||||
https://tectum.io/blog/dex-tools/
|
||||
http://0.0.0.0:8025/img.png
|
||||
https://localhost:8080/img.jpg
|
||||
http://localhost:8080/
|
||||
https://localhost/
|
||||
http://httpbin.org/#/
|
||||
https://snthonstcrgrfonhenth.com/nthshtf
|
||||
http://domain/.well-known/acme-challenge/token
|
||||
https://<strong>dextools</strong>.apiable.io/(Only
|
||||
|
||||
## Generated Links Test Case
|
||||
|
||||
1. https://www.google.com
|
||||
2. http://example.com/path/to/resource?query=value#fragment
|
||||
3. https://images.unsplash.com/photo-1575936123452-b67c3203c357
|
||||
4. file:///home/user/document.txt
|
||||
5. https://user:password@example.com:8080/path
|
||||
6. http://192.168.1.1:8080
|
||||
7. https://www.example.com/path with spaces/file.html
|
||||
8. ftp://ftp.example.com/pub/file.zip
|
||||
9. https://www.example.com/index.php?id=1&name=test
|
||||
10. https://subdomain.example.co.uk/path
|
||||
11. https://www.example.com/path/to/image.jpg
|
||||
12. https://www.example.com:8443/secure
|
||||
13. http://localhost:3000
|
||||
14. https://www.example.com/path/to/file.pdf#page=10
|
||||
15. https://www.example.com/search?q=query+with+spaces
|
||||
16. https://www.example.com/path/to/file.html#section-2
|
||||
17. https://www.example.com/path/to/file.php?id=123&action=view
|
||||
18. https://www.example.com/path/to/file.html?param1=value1¶m2=value2#fragment
|
||||
19. https://www.example.com/path/to/file.html?param=value with spaces
|
||||
20. https://www.example.com/path/to/file.html?param=value%20with%20encoded%20spaces
|
||||
21. https://www.example.com/path/to/file.html?param=value+with+plus+signs
|
||||
22. https://www.example.com/path/to/file.html?param=special@characters!
|
||||
23. https://www.example.com/path/to/file.html?param=special%40characters%21
|
||||
24. https://www.example.com/path/to/file.html?param=value¶m=duplicate
|
||||
25. https://www.example.com/path/to/file.html?param=
|
||||
26. https://www.example.com/path/to/file.html?=value
|
||||
27. https://www.example.com/path/to/file.html?
|
||||
28. https://www.example.com/path/to/file.html#
|
||||
29. https://www.example.com/path/to/file.html#fragment1#fragment2
|
||||
30. https://www.example.com/path/to/file.html?param1=value1#fragment?param2=value2
|
||||
31. https://www.example.com/index.html#!hashbang
|
||||
32. https://www.example.com/path/to/file.html?param=value#fragment=value
|
||||
33. https://www.example.com/path/to/file.html?param=value¶m2=value2#fragment
|
||||
34. https://www.example.com/path/to/file.html?param=value¶m2=value2#fragment=value
|
||||
35. https://www.example.com/path/to/file.html?param=value¶m2=value2#fragment?param3=value3
|
||||
36. https://www.example.com/path/to/file.html?param=value¶m2=value2#fragment¶m3=value3
|
||||
37. https://www.example.com/path/to/file.html?param=value¶m2=value2#fragment#fragment2
|
||||
38. https://www.example.com/path/to/file.html?param=value¶m2=value2#fragment/path
|
||||
39. https://www.example.com/path/to/file.html?param=value¶m2=value2#fragment?param3=value3¶m4=value4
|
||||
40. https://www.example.com/path/to/file.html?param=value¶m2=value2#fragment¶m3=value3¶m4=value4
|
||||
41. data:text/html,<h1>Hello World</h1>
|
||||
42. data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==
|
||||
43. javascript:alert('XSS')
|
||||
44. mailto:user@example.com
|
||||
45. tel:+1-234-567-8901
|
||||
46. sms:+1-234-567-8901?body=Hello
|
||||
47. https://www.example.com/path/to/file.html?param=<script>alert('XSS')</script>
|
||||
48. https://www.example.com/path/to/file.html?param=<img src="x" onerror="alert('XSS')">
|
||||
49. https://www.example.com/path/to/file.html?param=javascript:alert('XSS')
|
||||
50. https://www.example.com/path/to/file.html?param=data:text/html,<script>alert('XSS')</script>
|
||||
51. https://www.example.com/path/to/file.html?param=data:image/svg+xml,<svg onload="alert('XSS')">
|
||||
52. https://www.example.com/path/to/file.html?param=<iframe src="javascript:alert('XSS')">
|
||||
53. https://www.example.com/path/to/file.html?param=<a href="javascript:alert('XSS')">Click me</a>
|
||||
54. https://www.example.com/path/to/file.html?param=<img src="x" onerror="alert('XSS')">
|
||||
55. https://www.example.com/path/to/file.html?param=<svg><script>alert('XSS')</script></svg>
|
||||
56. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
57. https://www.example.com/path/to/file.html?param=<img src="x" onerror="alert('XSS')">
|
||||
58. https://www.example.com/path/to/file.html?param=<body onload="alert('XSS')">
|
||||
59. https://www.example.com/path/to/file.html?param=<input autofocus onfocus="alert('XSS')">
|
||||
60. https://www.example.com/path/to/file.html?param=<video src="x" onerror="alert('XSS')">
|
||||
61. https://www.example.com/path/to/file.html?param=<audio src="x" onerror="alert('XSS')">
|
||||
62. https://www.example.com/path/to/file.html?param=<iframe srcdoc="<script>alert('XSS')</script>">
|
||||
63. https://www.example.com/path/to/file.html?param=<math><maction actiontype="statusline#" xlink:href="javascript:alert('XSS')">Click
|
||||
64. https://www.example.com/path/to/file.html?param=<form action="javascript:alert('XSS')"><input type="submit">
|
||||
65. https://www.example.com/path/to/file.html?param=<isindex action="javascript:alert('XSS')" type="image">
|
||||
66. https://www.example.com/path/to/file.html?param=<object data="javascript:alert('XSS')">
|
||||
67. https://www.example.com/path/to/file.html?param=<embed src="javascript:alert('XSS')">
|
||||
68. https://www.example.com/path/to/file.html?param=<svg><script>alert('XSS')</script>
|
||||
69. https://www.example.com/path/to/file.html?param=<marquee onstart="alert('XSS')">
|
||||
70. https://www.example.com/path/to/file.html?param=<div style="background-image: url(javascript:alert('XSS'))">
|
||||
71. https://www.example.com/path/to/file.html?param=<link rel="stylesheet" href="javascript:alert('XSS')">
|
||||
72. https://www.example.com/path/to/file.html?param=<table background="javascript:alert('XSS')">
|
||||
73. https://www.example.com/path/to/file.html?param=<div style="width: expression(alert('XSS'))">
|
||||
74. https://www.example.com/path/to/file.html?param=<style>@import "javascript:alert('XSS')";</style>
|
||||
75. https://www.example.com/path/to/file.html?param=<meta http-equiv="refresh" content="0;url=javascript:alert('XSS')">
|
||||
76. https://www.example.com/path/to/file.html?param=<iframe src="data:text/html,<script>alert('XSS')</script>">
|
||||
77. https://www.example.com/path/to/file.html?param=<svg><set attributeName="onload" to="alert('XSS')" />
|
||||
78. https://www.example.com/path/to/file.html?param=<script>alert('XSS')</script>
|
||||
79. https://www.example.com/path/to/file.html?param=<img src="x" onerror="alert('XSS')">
|
||||
80. https://www.example.com/path/to/file.html?param=<svg><animate xlink:href="#xss" attributeName="href" values="javascript:alert('XSS')" />
|
||||
81. https://www.example.com/path/to/file.html?param=<svg><a><animate attributeName="href" values="javascript:alert('XSS')" />
|
||||
82. https://www.example.com/path/to/file.html?param=<svg><a xlink:href="javascript:alert('XSS')"><text x="20" y="20">XSS</text></a>
|
||||
83. https://www.example.com/path/to/file.html?param=<svg><a><animate attributeName="href" values="javascript:alert('XSS')" /><text x="20" y="20">XSS</text></a>
|
||||
84. https://www.example.com/path/to/file.html?param=<svg><discard onbegin="alert('XSS')" />
|
||||
85. https://www.example.com/path/to/file.html?param=<svg><script>alert('XSS')</script></svg>
|
||||
86. https://www.example.com/path/to/file.html?param=<svg><script>alert('XSS')</script>
|
||||
87. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
88. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
89. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
90. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
91. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
92. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
93. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
94. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
95. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
96. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
97. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
98. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
99. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
100. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
101. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
102. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
103. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
104. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
105. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
106. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
107. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
108. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
|
||||
|
||||
## Popular URLs by Popularity Test Case
|
||||
|
||||
1. https://www.google.com
|
||||
2. https://www.blogger.com
|
||||
3. https://youtube.com
|
||||
4. https://linkedin.com
|
||||
5. https://support.google.com
|
||||
6. https://cloudflare.com
|
||||
7. https://microsoft.com
|
||||
8. https://apple.com
|
||||
9. https://en.wikipedia.org
|
||||
10. https://play.google.com
|
||||
11. https://wordpress.org
|
||||
12. https://docs.google.com
|
||||
13. https://mozilla.org
|
||||
14. https://maps.google.com
|
||||
15. https://youtu.be
|
||||
16. https://drive.google.com
|
||||
17. https://bp.blogspot.com
|
||||
18. https://sites.google.com
|
||||
19. https://googleusercontent.com
|
||||
20. https://accounts.google.com
|
||||
21. https://t.me
|
||||
22. https://europa.eu
|
||||
23. https://plus.google.com
|
||||
24. https://whatsapp.com
|
||||
25. https://adobe.com
|
||||
26. https://facebook.com
|
||||
27. https://policies.google.com
|
||||
28. https://uol.com.br
|
||||
29. https://istockphoto.com
|
||||
30. https://vimeo.com
|
||||
31. https://vk.com
|
||||
32. https://github.com
|
||||
33. https://amazon.com
|
||||
34. https://search.google.com
|
||||
35. https://bbc.co.uk
|
||||
36. https://google.de
|
||||
37. https://live.com
|
||||
38. https://gravatar.com
|
||||
39. https://nih.gov
|
||||
40. https://dan.com
|
||||
41. https://files.wordpress.com
|
||||
42. https://www.yahoo.com
|
||||
43. https://cnn.com
|
||||
44. https://dropbox.com
|
||||
45. https://wikimedia.org
|
||||
46. https://creativecommons.org
|
||||
47. https://google.com.br
|
||||
48. https://line.me
|
||||
49. https://googleblog.com
|
||||
50. https://opera.com
|
||||
51. https://es.wikipedia.org
|
||||
52. https://globo.com
|
||||
53. https://brandbucket.com
|
||||
54. https://myspace.com
|
||||
55. https://slideshare.net
|
||||
56. https://paypal.com
|
||||
57. https://tiktok.com
|
||||
58. https://netvibes.com
|
||||
59. https://theguardian.com
|
||||
60. https://who.int
|
||||
61. https://goo.gl
|
||||
62. https://medium.com
|
||||
63. https://tools.google.com
|
||||
64. https://draft.blogger.com
|
||||
65. https://pt.wikipedia.org
|
||||
66. https://fr.wikipedia.org
|
||||
67. https://www.weebly.com
|
||||
68. https://news.google.com
|
||||
69. https://developers.google.com
|
||||
70. https://w3.org
|
||||
71. https://mail.google.com
|
||||
72. https://gstatic.com
|
||||
73. https://jimdofree.com
|
||||
74. https://cpanel.net
|
||||
75. https://imdb.com
|
||||
76. https://wa.me
|
||||
77. https://feedburner.com
|
||||
78. https://enable-javascript.com
|
||||
79. https://nytimes.com
|
||||
80. https://workspace.google.com
|
||||
81. https://ok.ru
|
||||
82. https://google.es
|
||||
83. https://dailymotion.com
|
||||
84. https://afternic.com
|
||||
85. https://bloomberg.com
|
||||
86. https://amazon.de
|
||||
87. https://photos.google.com
|
||||
88. https://wiley.com
|
||||
89. https://aliexpress.com
|
||||
90. https://indiatimes.com
|
||||
91. https://youronlinechoices.com
|
||||
92. https://elpais.com
|
||||
93. https://tinyurl.com
|
||||
94. https://yadi.sk
|
||||
95. https://spotify.com
|
||||
96. https://huffpost.com
|
||||
97. https://ru.wikipedia.org
|
||||
98. https://google.fr
|
||||
99. https://webmd.com
|
||||
100. https://samsung.com
|
||||
101. https://independent.co.uk
|
||||
102. https://amazon.co.jp
|
||||
103. https://get.google.com
|
||||
104. https://amazon.co.uk
|
||||
105. https://4shared.com
|
||||
106. https://telegram.me
|
||||
107. https://planalto.gov.br
|
||||
108. https://businessinsider.com
|
||||
109. https://ig.com.br
|
||||
110. https://issuu.com
|
||||
111. https://www.gov.br
|
||||
112. https://wsj.com
|
||||
113. https://hugedomains.com
|
||||
114. https://picasaweb.google.com
|
||||
115. https://usatoday.com
|
||||
116. https://scribd.com
|
||||
117. https://www.gov.uk
|
||||
118. https://storage.googleapis.com
|
||||
119. https://huffingtonpost.com
|
||||
120. https://bbc.com
|
||||
121. https://estadao.com.br
|
||||
122. https://nature.com
|
||||
123. https://mediafire.com
|
||||
124. https://washingtonpost.com
|
||||
125. https://forms.gle
|
||||
126. https://namecheap.com
|
||||
127. https://forbes.com
|
||||
128. https://mirror.co.uk
|
||||
129. https://soundcloud.com
|
||||
130. https://fb.com
|
||||
131. https://marketingplatform.google
|
||||
132. https://domainmarket.com
|
||||
133. https://ytimg.com
|
||||
134. https://terra.com.br
|
||||
135. https://google.co.uk
|
||||
136. https://shutterstock.com
|
||||
137. https://dailymail.co.uk
|
||||
138. https://reg.ru
|
||||
139. https://t.co
|
||||
140. https://cdc.gov
|
||||
141. https://thesun.co.uk
|
||||
142. https://wp.com
|
||||
143. https://cnet.com
|
||||
144. https://instagram.com
|
||||
145. https://researchgate.net
|
||||
146. https://google.it
|
||||
147. https://fandom.com
|
||||
148. https://office.com
|
||||
149. https://list-manage.com
|
||||
150. https://msn.com
|
||||
151. https://un.org
|
||||
152. https://de.wikipedia.org
|
||||
153. https://ovh.com
|
||||
154. https://mail.ru
|
||||
155. https://bing.com
|
||||
156. https://news.yahoo.com
|
||||
157. https://myaccount.google.com
|
||||
158. https://hatena.ne.jp
|
||||
159. https://shopify.com
|
||||
160. https://adssettings.google.com
|
||||
161. https://bit.ly
|
||||
162. https://reuters.com
|
||||
163. https://booking.com
|
||||
164. https://discord.com
|
||||
165. https://buydomains.com
|
||||
166. https://nasa.gov
|
||||
167. https://aboutads.info
|
||||
168. https://time.com
|
||||
169. https://abril.com.br
|
||||
170. https://change.org
|
||||
171. https://nginx.org
|
||||
172. https://twitter.com
|
||||
173. https://www.wikipedia.org
|
||||
174. https://archive.org
|
||||
175. https://cbsnews.com
|
||||
176. https://networkadvertising.org
|
||||
177. https://telegraph.co.uk
|
||||
178. https://pinterest.com
|
||||
179. https://google.co.jp
|
||||
180. https://pixabay.com
|
||||
181. https://zendesk.com
|
||||
182. https://cpanel.com
|
||||
183. https://vistaprint.com
|
||||
184. https://sky.com
|
||||
185. https://windows.net
|
||||
186. https://alicdn.com
|
||||
187. https://google.ca
|
||||
188. https://lemonde.fr
|
||||
189. https://newyorker.com
|
||||
190. https://webnode.page
|
||||
191. https://surveymonkey.com
|
||||
192. https://translate.google.com
|
||||
193. https://calendar.google.com
|
||||
194. https://amazonaws.com
|
||||
195. https://academia.edu
|
||||
196. https://apache.org
|
||||
197. https://imageshack.us
|
||||
198. https://akamaihd.net
|
||||
199. https://nginx.com
|
||||
200. https://discord.gg
|
||||
201. https://thetimes.co.uk
|
||||
202. https://search.yahoo.com
|
||||
203. https://amazon.fr
|
||||
204. https://yelp.com
|
||||
205. https://berkeley.edu
|
||||
206. https://google.ru
|
||||
207. https://sedoparking.com
|
||||
208. https://cbc.ca
|
||||
209. https://unesco.org
|
||||
210. https://ggpht.com
|
||||
211. https://privacyshield.gov
|
||||
212. https://www.over-blog.com
|
||||
213. https://clarin.com
|
||||
214. https://www.wix.com
|
||||
215. https://whitehouse.gov
|
||||
216. https://icann.org
|
||||
217. https://gnu.org
|
||||
218. https://yandex.ru
|
||||
219. https://francetvinfo.fr
|
||||
220. https://gmail.com
|
||||
221. https://mozilla.com
|
||||
222. https://ziddu.com
|
||||
223. https://guardian.co.uk
|
||||
224. https://twitch.tv
|
||||
225. https://sedo.com
|
||||
226. https://foxnews.com
|
||||
227. https://rambler.ru
|
||||
228. https://books.google.com
|
||||
229. https://stanford.edu
|
||||
230. https://wikihow.com
|
||||
231. https://it.wikipedia.org
|
||||
232. https://20minutos.es
|
||||
233. https://sfgate.com
|
||||
234. https://liveinternet.ru
|
||||
235. https://ja.wikipedia.org
|
||||
236. https://000webhost.com
|
||||
237. https://espn.com
|
||||
238. https://eventbrite.com
|
||||
239. https://disney.com
|
||||
240. https://statista.com
|
||||
241. https://addthis.com
|
||||
242. https://pinterest.fr
|
||||
243. https://lavanguardia.com
|
||||
244. https://vkontakte.ru
|
||||
245. https://doubleclick.net
|
||||
246. https://bp2.blogger.com
|
||||
247. https://skype.com
|
||||
248. https://sciencedaily.com
|
||||
249. https://bloglovin.com
|
||||
250. https://insider.com
|
||||
251. https://pl.wikipedia.org
|
||||
252. https://sputniknews.com
|
||||
253. https://id.wikipedia.org
|
||||
254. https://doi.org
|
||||
255. https://nypost.com
|
||||
256. https://elmundo.es
|
||||
257. https://abcnews.go.com
|
||||
258. https://ipv4.google.com
|
||||
259. https://deezer.com
|
||||
260. https://express.co.uk
|
||||
261. https://detik.com
|
||||
262. https://mystrikingly.com
|
||||
263. https://rakuten.co.jp
|
||||
264. https://amzn.to
|
||||
265. https://arxiv.org
|
||||
266. https://alibaba.com
|
||||
267. https://fb.me
|
||||
268. https://wikia.com
|
||||
269. https://t-online.de
|
||||
270. https://telegra.ph
|
||||
271. https://mega.nz
|
||||
272. https://usnews.com
|
||||
273. https://plos.org
|
||||
274. https://naver.com
|
||||
275. https://ibm.com
|
||||
276. https://smh.com.au
|
||||
277. https://dw.com
|
||||
278. https://google.nl
|
||||
279. https://lefigaro.fr
|
||||
280. https://bp1.blogger.com
|
||||
281. https://picasa.google.com
|
||||
282. https://theatlantic.com
|
||||
283. https://nydailynews.com
|
||||
284. https://themeforest.net
|
||||
285. https://rtve.es
|
||||
286. https://newsweek.com
|
||||
287. https://ovh.net
|
||||
288. https://ca.gov
|
||||
289. https://goodreads.com
|
||||
290. https://economist.com
|
||||
291. https://target.com
|
||||
292. https://marca.com
|
||||
293. https://kickstarter.com
|
||||
294. https://hindustantimes.com
|
||||
295. https://weibo.com
|
||||
296. https://finance.yahoo.com
|
||||
297. https://huawei.com
|
||||
298. https://e-monsite.com
|
||||
299. https://hubspot.com
|
||||
300. https://npr.org
|
||||
301. https://netflix.com
|
||||
302. https://gizmodo.com
|
||||
303. https://netlify.app
|
||||
304. https://yandex.com
|
||||
305. https://mashable.com
|
||||
306. https://cnil.fr
|
||||
307. https://latimes.com
|
||||
308. https://steampowered.com
|
||||
309. https://rt.com
|
||||
310. https://photobucket.com
|
||||
311. https://quora.com
|
||||
312. https://nbcnews.com
|
||||
313. https://android.com
|
||||
314. https://instructables.com
|
||||
315. https://www.canalblog.com
|
||||
316. https://www.livejournal.com
|
||||
317. https://ouest-france.fr
|
||||
318. https://tripadvisor.com
|
||||
319. https://ovhcloud.com
|
||||
320. https://pexels.com
|
||||
321. https://oracle.com
|
||||
322. https://yahoo.co.jp
|
||||
323. https://addtoany.com
|
||||
324. https://sakura.ne.jp
|
||||
325. https://cointernet.com.co
|
||||
326. https://twimg.com
|
||||
327. https://britannica.com
|
||||
328. https://php.net
|
||||
329. https://standard.co.uk
|
||||
330. https://groups.google.com
|
||||
331. https://cnbc.com
|
||||
332. https://loc.gov
|
||||
333. https://qq.com
|
||||
334. https://buzzfeed.com
|
||||
335. https://godaddy.com
|
||||
336. https://ikea.com
|
||||
337. https://disqus.com
|
||||
338. https://taringa.net
|
||||
339. https://ea.com
|
||||
340. https://dropcatch.com
|
||||
341. https://techcrunch.com
|
||||
342. https://canva.com
|
||||
343. https://offset.com
|
||||
344. https://ebay.com
|
||||
345. https://zoom.us
|
||||
346. https://cambridge.org
|
||||
347. https://unsplash.com
|
||||
348. https://playstation.com
|
||||
349. https://people.com
|
||||
350. https://springer.com
|
||||
351. https://psychologytoday.com
|
||||
352. https://sendspace.com
|
||||
353. https://home.pl
|
||||
354. https://rapidshare.com
|
||||
355. https://prezi.com
|
||||
356. https://photos1.blogger.com
|
||||
357. https://thenai.org
|
||||
358. https://ftc.gov
|
||||
359. https://google.pl
|
||||
360. https://ted.com
|
||||
361. https://secureserver.net
|
||||
362. https://code.google.com
|
||||
363. https://plesk.com
|
||||
364. https://aol.com
|
||||
365. https://biglobe.ne.jp
|
||||
366. https://hp.com
|
||||
367. https://canada.ca
|
||||
368. https://linktr.ee
|
||||
369. https://hollywoodreporter.com
|
||||
370. https://ietf.org
|
||||
371. https://clickbank.net
|
||||
372. https://harvard.edu
|
||||
373. https://amazon.es
|
||||
374. https://oup.com
|
||||
375. https://timeweb.ru
|
||||
376. https://engadget.com
|
||||
377. https://vice.com
|
||||
378. https://cornell.edu
|
||||
379. https://dreamstime.com
|
||||
380. https://tmz.com
|
||||
381. https://gofundme.com
|
||||
382. https://pbs.org
|
||||
383. https://stackoverflow.com
|
||||
384. https://abc.net.au
|
||||
385. https://sciencedirect.com
|
||||
386. https://ft.com
|
||||
387. https://variety.com
|
||||
388. https://alexa.com
|
||||
389. https://abc.es
|
||||
390. https://walmart.com
|
||||
391. https://gooyaabitemplates.com
|
||||
392. https://redbull.com
|
||||
393. https://ssl-images-amazon.com
|
||||
394. https://theverge.com
|
||||
395. https://spiegel.de
|
||||
396. https://about.com
|
||||
397. https://nationalgeographic.com
|
||||
398. https://bandcamp.com
|
||||
399. https://m.wikipedia.org
|
||||
400. https://zippyshare.com
|
||||
401. https://wired.com
|
||||
402. https://freepik.com
|
||||
403. https://outlook.com
|
||||
404. https://mit.edu
|
||||
405. https://sapo.pt
|
||||
406. https://goo.ne.jp
|
||||
407. https://java.com
|
||||
408. https://google.co.th
|
||||
409. https://scmp.com
|
||||
410. https://mayoclinic.org
|
||||
411. https://scholastic.com
|
||||
412. https://nba.com
|
||||
413. https://reverbnation.com
|
||||
414. https://depositfiles.com
|
||||
415. https://video.google.com
|
||||
416. https://howstuffworks.com
|
||||
417. https://cbslocal.com
|
||||
418. https://merriam-webster.com
|
||||
419. https://focus.de
|
||||
420. https://admin.ch
|
||||
421. https://gfycat.com
|
||||
422. https://com.com
|
||||
423. https://narod.ru
|
||||
424. https://boston.com
|
||||
425. https://sony.com
|
||||
426. https://justjared.com
|
||||
427. https://bitly.com
|
||||
428. https://jstor.org
|
||||
429. https://amebaownd.com
|
||||
430. https://g.co
|
||||
431. https://gsmarena.com
|
||||
432. https://lexpress.fr
|
||||
433. https://reddit.com
|
||||
434. https://usgs.gov
|
||||
435. https://bigcommerce.com
|
||||
436. https://gettyimages.com
|
||||
437. https://ign.com
|
||||
438. https://justgiving.com
|
||||
439. https://techradar.com
|
||||
440. https://weather.com
|
||||
441. https://amazon.ca
|
||||
442. https://justice.gov
|
||||
443. https://sciencemag.org
|
||||
444. https://pcmag.com
|
||||
445. https://theconversation.com
|
||||
446. https://foursquare.com
|
||||
447. https://flickr.com
|
||||
448. https://giphy.com
|
||||
449. https://tvtropes.org
|
||||
450. https://fifa.com
|
||||
451. https://upenn.edu
|
||||
452. https://digg.com
|
||||
453. https://bestfreecams.club
|
||||
454. https://histats.com
|
||||
455. https://salesforce.com
|
||||
456. https://blog.google
|
||||
457. https://apnews.com
|
||||
458. https://theglobeandmail.com
|
||||
459. https://m.me
|
||||
460. https://europapress.es
|
||||
461. https://washington.edu
|
||||
462. https://thefreedictionary.com
|
||||
463. https://jhu.edu
|
||||
464. https://euronews.com
|
||||
465. https://liberation.fr
|
||||
466. https://ads.google.com
|
||||
467. https://trustpilot.com
|
||||
468. https://google.com.tw
|
||||
469. https://softonic.com
|
||||
470. https://kakao.com
|
||||
471. https://storage.canalblog.com
|
||||
472. https://interia.pl
|
||||
473. https://metro.co.uk
|
||||
474. https://viglink.com
|
||||
475. https://last.fm
|
||||
476. https://blackberry.com
|
||||
477. https://public-api.wordpress.com
|
||||
478. https://sina.com.cn
|
||||
479. https://unicef.org
|
||||
480. https://archives.gov
|
||||
481. https://nps.gov
|
||||
482. https://utexas.edu
|
||||
483. https://biblegateway.com
|
||||
484. https://usda.gov
|
||||
485. https://indiegogo.com
|
||||
486. https://nikkei.com
|
||||
487. https://radiofrance.fr
|
||||
488. https://repubblica.it
|
||||
489. https://substack.com
|
||||
490. https://ap.org
|
||||
491. https://nicovideo.jp
|
||||
492. https://joomla.org
|
||||
493. https://news.com.au
|
||||
494. https://allaboutcookies.org
|
||||
495. https://mailchimp.com
|
||||
496. https://stores.jp
|
||||
497. https://intel.com
|
||||
498. https://bp0.blogger.com
|
||||
499. https://box.com
|
||||
499. https://nhk.or.jp
|
||||
Reference in New Issue
Block a user