mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-05 08:10:14 +00:00
webview: never render base64; render backend-saved image URIs; allow globalStorage URIs; fix 401
This commit is contained in:
parent
f934363beb
commit
e71ec43d0b
12 changed files with 301 additions and 66 deletions
|
|
@ -256,7 +256,8 @@ export const openClineInNewTab = async ({ context, outputChannel }: Omit<Registe
|
|||
const newPanel = vscode.window.createWebviewPanel(ClineProvider.tabPanelId, "Roo Code", targetCol, {
|
||||
enableScripts: true,
|
||||
retainContextWhenHidden: true,
|
||||
localResourceRoots: [context.extensionUri],
|
||||
// Allow webview to load images saved under globalStorageUri
|
||||
localResourceRoots: [context.extensionUri, context.globalStorageUri],
|
||||
})
|
||||
|
||||
// Save as tab type panel.
|
||||
|
|
|
|||
|
|
@ -728,7 +728,7 @@ export class ClineProvider
|
|||
})
|
||||
|
||||
// Set up webview options with proper resource roots
|
||||
const resourceRoots = [this.contextProxy.extensionUri]
|
||||
const resourceRoots = [this.contextProxy.extensionUri, this.contextProxy.globalStorageUri]
|
||||
|
||||
// Add workspace folders to allow access to workspace files
|
||||
if (vscode.workspace.workspaceFolders) {
|
||||
|
|
@ -1008,7 +1008,7 @@ export class ClineProvider
|
|||
"default-src 'none'",
|
||||
`font-src ${webview.cspSource} data:`,
|
||||
`style-src ${webview.cspSource} 'unsafe-inline' https://* http://${localServerUrl} http://0.0.0.0:${localPort}`,
|
||||
`img-src ${webview.cspSource} https://storage.googleapis.com https://img.clerk.com data:`,
|
||||
`img-src ${webview.cspSource} https://*.vscode-cdn.net https://storage.googleapis.com https://img.clerk.com data:`,
|
||||
`media-src ${webview.cspSource}`,
|
||||
`script-src 'unsafe-eval' ${webview.cspSource} https://* https://*.posthog.com http://${localServerUrl} http://0.0.0.0:${localPort} 'nonce-${nonce}'`,
|
||||
`connect-src ${webview.cspSource} https://* https://*.posthog.com ws://${localServerUrl} ws://0.0.0.0:${localPort} http://${localServerUrl} http://0.0.0.0:${localPort}`,
|
||||
|
|
@ -1093,7 +1093,7 @@ export class ClineProvider
|
|||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no">
|
||||
<meta name="theme-color" content="#000000">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; font-src ${webview.cspSource} data:; style-src ${webview.cspSource} 'unsafe-inline'; img-src ${webview.cspSource} https://storage.googleapis.com https://img.clerk.com data:; media-src ${webview.cspSource}; script-src ${webview.cspSource} 'wasm-unsafe-eval' 'nonce-${nonce}' https://us-assets.i.posthog.com 'strict-dynamic'; connect-src ${webview.cspSource} https://openrouter.ai https://api.requesty.ai https://us.i.posthog.com https://us-assets.i.posthog.com;">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; font-src ${webview.cspSource} data:; style-src ${webview.cspSource} 'unsafe-inline'; img-src ${webview.cspSource} https://*.vscode-cdn.net https://storage.googleapis.com https://img.clerk.com data:; media-src ${webview.cspSource}; script-src ${webview.cspSource} 'wasm-unsafe-eval' 'nonce-${nonce}' https://us-assets.i.posthog.com 'strict-dynamic'; connect-src ${webview.cspSource} https://openrouter.ai https://api.requesty.ai https://us.i.posthog.com https://us-assets.i.posthog.com;">
|
||||
<link rel="stylesheet" type="text/css" href="${stylesUri}">
|
||||
<link href="${codiconsUri}" rel="stylesheet" />
|
||||
<script nonce="${nonce}">
|
||||
|
|
@ -2720,27 +2720,35 @@ export class ClineProvider
|
|||
*/
|
||||
public convertToWebviewUri(filePath: string): string {
|
||||
try {
|
||||
const fileUri = vscode.Uri.file(filePath)
|
||||
|
||||
// Check if we have a webview available
|
||||
// If a webview is available, generate a URI relative to an allowed localResourceRoot when possible.
|
||||
if (this.view?.webview) {
|
||||
let fileUri: vscode.Uri
|
||||
|
||||
// Prefer mapping under globalStorageUri to guarantee allow-list match for localResourceRoots
|
||||
const gsRoot = this.contextProxy?.globalStorageUri?.fsPath
|
||||
if (gsRoot && filePath.startsWith(gsRoot)) {
|
||||
// Build a URI under the globalStorage root using joinPath
|
||||
const rel = path.relative(gsRoot, filePath)
|
||||
const segments = rel.split(path.sep).filter(Boolean)
|
||||
fileUri = vscode.Uri.joinPath(this.contextProxy.globalStorageUri, ...segments)
|
||||
} else {
|
||||
// Fallback to direct file URI
|
||||
fileUri = vscode.Uri.file(filePath)
|
||||
}
|
||||
|
||||
const webviewUri = this.view.webview.asWebviewUri(fileUri)
|
||||
return webviewUri.toString()
|
||||
}
|
||||
|
||||
// Specific error for no webview available
|
||||
const error = new Error("No webview available for URI conversion")
|
||||
console.error(error.message)
|
||||
// Fallback to file URI if no webview available
|
||||
return fileUri.toString()
|
||||
// No webview available; fallback to file URI
|
||||
console.error("No webview available for URI conversion")
|
||||
return vscode.Uri.file(filePath).toString()
|
||||
} catch (error) {
|
||||
// More specific error handling
|
||||
if (error instanceof TypeError) {
|
||||
console.error("Invalid file path provided for URI conversion:", error)
|
||||
} else {
|
||||
console.error("Failed to convert to webview URI:", error)
|
||||
}
|
||||
// Return file URI as fallback
|
||||
return vscode.Uri.file(filePath).toString()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -456,7 +456,7 @@ describe("ClineProvider", () => {
|
|||
|
||||
expect(mockWebviewView.webview.options).toEqual({
|
||||
enableScripts: true,
|
||||
localResourceRoots: [mockContext.extensionUri],
|
||||
localResourceRoots: [mockContext.extensionUri, mockContext.globalStorageUri],
|
||||
})
|
||||
|
||||
expect(mockWebviewView.webview.html).toContain("<!DOCTYPE html>")
|
||||
|
|
@ -475,7 +475,7 @@ describe("ClineProvider", () => {
|
|||
|
||||
expect(mockWebviewView.webview.options).toEqual({
|
||||
enableScripts: true,
|
||||
localResourceRoots: [mockContext.extensionUri],
|
||||
localResourceRoots: [mockContext.extensionUri, mockContext.globalStorageUri],
|
||||
})
|
||||
|
||||
expect(mockWebviewView.webview.html).toContain("<!DOCTYPE html>")
|
||||
|
|
|
|||
|
|
@ -36,7 +36,12 @@ import { checkExistKey } from "../../shared/checkExistApiConfig"
|
|||
import { experimentDefault } from "../../shared/experiments"
|
||||
import { Terminal } from "../../integrations/terminal/Terminal"
|
||||
import { openFile } from "../../integrations/misc/open-file"
|
||||
import { openImage, saveImage } from "../../integrations/misc/image-handler"
|
||||
import {
|
||||
openImage,
|
||||
saveImage,
|
||||
savePastedImageToTemp,
|
||||
importImageToGlobalStorage,
|
||||
} from "../../integrations/misc/image-handler"
|
||||
import { selectImages } from "../../integrations/misc/process-images"
|
||||
import { getTheme } from "../../integrations/theme/getTheme"
|
||||
import { discoverChromeHostUrl, tryChromeHostUrl } from "../../services/browser/browserDiscovery"
|
||||
|
|
@ -614,13 +619,24 @@ export const webviewMessageHandler = async (
|
|||
await provider.postStateToWebview()
|
||||
break
|
||||
case "selectImages":
|
||||
const images = await selectImages()
|
||||
await provider.postMessageToWebview({
|
||||
type: "selectedImages",
|
||||
images,
|
||||
context: message.context,
|
||||
messageTs: message.messageTs,
|
||||
})
|
||||
// Copy selected images into global storage and return webview-safe URIs
|
||||
{
|
||||
const pickedPaths = await selectImages()
|
||||
const results = await Promise.all(
|
||||
(pickedPaths || []).map((p) => importImageToGlobalStorage(p, provider)),
|
||||
)
|
||||
// Ensure URIs are derived via current webview context if available
|
||||
const images = results
|
||||
.filter((r): r is { imagePath: string; imageUri: string } => !!r)
|
||||
.map((r) => provider?.convertToWebviewUri?.(r.imagePath) ?? r.imageUri)
|
||||
|
||||
await provider.postMessageToWebview({
|
||||
type: "selectedImages",
|
||||
images,
|
||||
context: message.context,
|
||||
messageTs: message.messageTs,
|
||||
})
|
||||
}
|
||||
break
|
||||
case "exportCurrentTask":
|
||||
const currentTaskId = provider.getCurrentTask()?.taskId
|
||||
|
|
@ -3035,5 +3051,26 @@ export const webviewMessageHandler = async (
|
|||
})
|
||||
break
|
||||
}
|
||||
case "savePastedImage": {
|
||||
// Save pasted image to temporary file and return path and URI
|
||||
if (message.dataUri) {
|
||||
const result = await savePastedImageToTemp(message.dataUri, provider)
|
||||
if (result) {
|
||||
await provider.postMessageToWebview({
|
||||
type: "pastedImageSaved",
|
||||
imagePath: result.imagePath,
|
||||
imageUri: result.imageUri,
|
||||
requestId: message.requestId,
|
||||
})
|
||||
} else {
|
||||
await provider.postMessageToWebview({
|
||||
type: "pastedImageSaved",
|
||||
error: "Failed to save pasted image",
|
||||
requestId: message.requestId,
|
||||
})
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import * as path from "path"
|
||||
import * as os from "os"
|
||||
import * as vscode from "vscode"
|
||||
import * as fs from "fs/promises"
|
||||
import { getWorkspacePath } from "../../utils/path"
|
||||
import { t } from "../../i18n"
|
||||
|
||||
|
|
@ -42,7 +43,6 @@ export async function openImage(dataUriOrPath: string, options?: { values?: { ac
|
|||
return
|
||||
}
|
||||
|
||||
// Handle data URI (existing logic)
|
||||
const matches = dataUriOrPath.match(/^data:image\/([a-zA-Z]+);base64,(.+)$/)
|
||||
if (!matches) {
|
||||
vscode.window.showErrorMessage(t("common:errors.invalid_data_uri"))
|
||||
|
|
@ -90,6 +90,108 @@ export async function openImage(dataUriOrPath: string, options?: { values?: { ac
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a pasted/dropped image to global storage and return its path and webview URI
|
||||
* This uses VSCode's global storage for persistence across sessions
|
||||
*/
|
||||
export async function importImageToGlobalStorage(
|
||||
imagePath: string,
|
||||
provider?: any,
|
||||
): Promise<{ imagePath: string; imageUri: string } | null> {
|
||||
try {
|
||||
// Determine storage directory (global storage preferred, fallback to temp)
|
||||
let imagesDir: string
|
||||
if (provider?.contextProxy?.globalStorageUri) {
|
||||
const globalStoragePath = provider.contextProxy.globalStorageUri.fsPath
|
||||
const taskId = provider.getCurrentTask?.()?.taskId
|
||||
imagesDir = taskId
|
||||
? path.join(globalStoragePath, "user-images", `task-${taskId}`)
|
||||
: path.join(globalStoragePath, "user-images", "general")
|
||||
} else {
|
||||
console.warn("Provider context not available, falling back to temp directory")
|
||||
imagesDir = path.join(os.tmpdir(), "roo-user-images")
|
||||
}
|
||||
|
||||
await fs.mkdir(imagesDir, { recursive: true })
|
||||
|
||||
// Preserve original extension if possible
|
||||
const ext = path.extname(imagePath) || ".png"
|
||||
const timestamp = Date.now()
|
||||
const randomId = Math.random().toString(36).substring(2, 8)
|
||||
const destFileName = `imported_image_${timestamp}_${randomId}${ext}`
|
||||
const destPath = path.join(imagesDir, destFileName)
|
||||
|
||||
// Copy the original image into global storage
|
||||
await fs.copyFile(imagePath, destPath)
|
||||
|
||||
// Convert to webview URI
|
||||
let webviewUri = provider?.convertToWebviewUri?.(destPath) ?? vscode.Uri.file(destPath).toString()
|
||||
|
||||
return { imagePath: destPath, imageUri: webviewUri }
|
||||
} catch (error) {
|
||||
console.error("Failed to import image into global storage:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function savePastedImageToTemp(
|
||||
dataUri: string,
|
||||
provider?: any,
|
||||
): Promise<{ imagePath: string; imageUri: string } | null> {
|
||||
const matches = dataUri.match(/^data:image\/([a-zA-Z]+);base64,(.+)$/)
|
||||
if (!matches) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [, format, base64Data] = matches
|
||||
const imageBuffer = Buffer.from(base64Data, "base64")
|
||||
|
||||
// Determine storage directory
|
||||
let imagesDir: string
|
||||
|
||||
// Use global storage if provider context is available
|
||||
if (provider?.contextProxy?.globalStorageUri) {
|
||||
const globalStoragePath = provider.contextProxy.globalStorageUri.fsPath
|
||||
|
||||
// Organize by task ID if available
|
||||
const taskId = provider.getCurrentTask?.()?.taskId
|
||||
if (taskId) {
|
||||
imagesDir = path.join(globalStoragePath, "pasted-images", `task-${taskId}`)
|
||||
} else {
|
||||
// Fallback to general pasted-images directory
|
||||
imagesDir = path.join(globalStoragePath, "pasted-images", "general")
|
||||
}
|
||||
} else {
|
||||
// Fallback to temp directory if provider context is not available
|
||||
console.warn("Provider context not available, falling back to temp directory")
|
||||
imagesDir = path.join(os.tmpdir(), "roo-pasted-images")
|
||||
}
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
await fs.mkdir(imagesDir, { recursive: true })
|
||||
|
||||
// Generate a unique filename
|
||||
const timestamp = Date.now()
|
||||
const randomId = Math.random().toString(36).substring(2, 8)
|
||||
const fileName = `pasted_image_${timestamp}_${randomId}.${format}`
|
||||
const imagePath = path.join(imagesDir, fileName)
|
||||
|
||||
try {
|
||||
// Write the image to the file
|
||||
await fs.writeFile(imagePath, imageBuffer)
|
||||
|
||||
// Convert to webview URI if provider is available
|
||||
let imageUri = provider?.convertToWebviewUri?.(imagePath) ?? vscode.Uri.file(imagePath).toString()
|
||||
|
||||
// Do not append custom query params to VS Code webview URIs (can break auth token and cause 401)
|
||||
|
||||
return { imagePath, imageUri }
|
||||
} catch (error) {
|
||||
console.error("Failed to save pasted image:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveImage(dataUri: string) {
|
||||
const matches = dataUri.match(/^data:image\/([a-zA-Z]+);base64,(.+)$/)
|
||||
if (!matches) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import * as vscode from "vscode"
|
||||
import fs from "fs/promises"
|
||||
import * as path from "path"
|
||||
|
||||
/**
|
||||
* Open a file picker to select images, returning absolute file system paths.
|
||||
* Rendering-friendly webview URIs will be produced in the webviewMessageHandler.
|
||||
*/
|
||||
export async function selectImages(): Promise<string[]> {
|
||||
const options: vscode.OpenDialogOptions = {
|
||||
canSelectMany: true,
|
||||
|
|
@ -12,34 +14,10 @@ export async function selectImages(): Promise<string[]> {
|
|||
}
|
||||
|
||||
const fileUris = await vscode.window.showOpenDialog(options)
|
||||
|
||||
if (!fileUris || fileUris.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
return await Promise.all(
|
||||
fileUris.map(async (uri) => {
|
||||
const imagePath = uri.fsPath
|
||||
const buffer = await fs.readFile(imagePath)
|
||||
const base64 = buffer.toString("base64")
|
||||
const mimeType = getMimeType(imagePath)
|
||||
const dataUrl = `data:${mimeType};base64,${base64}`
|
||||
return dataUrl
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function getMimeType(filePath: string): string {
|
||||
const ext = path.extname(filePath).toLowerCase()
|
||||
switch (ext) {
|
||||
case ".png":
|
||||
return "image/png"
|
||||
case ".jpeg":
|
||||
case ".jpg":
|
||||
return "image/jpeg"
|
||||
case ".webp":
|
||||
return "image/webp"
|
||||
default:
|
||||
throw new Error(`Unsupported file type: ${ext}`)
|
||||
}
|
||||
// Return fs paths only; do not read/encode files here.
|
||||
return fileUris.map((uri) => uri.fsPath)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,6 +124,7 @@ export interface ExtensionMessage {
|
|||
| "commands"
|
||||
| "insertTextIntoTextarea"
|
||||
| "dismissedUpsells"
|
||||
| "pastedImageSaved"
|
||||
text?: string
|
||||
payload?: any // Add a generic payload for now, can refine later
|
||||
action?:
|
||||
|
|
@ -201,6 +202,8 @@ export interface ExtensionMessage {
|
|||
commands?: Command[]
|
||||
queuedMessages?: QueuedMessage[]
|
||||
list?: string[] // For dismissedUpsells
|
||||
imagePath?: string // For pastedImageSaved
|
||||
imageUri?: string // For pastedImageSaved
|
||||
}
|
||||
|
||||
export type ExtensionState = Pick<
|
||||
|
|
|
|||
|
|
@ -225,6 +225,7 @@ export interface WebviewMessage {
|
|||
| "editQueuedMessage"
|
||||
| "dismissUpsell"
|
||||
| "getDismissedUpsells"
|
||||
| "savePastedImage"
|
||||
text?: string
|
||||
editedMessageContent?: string
|
||||
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud"
|
||||
|
|
|
|||
|
|
@ -121,7 +121,23 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
const messageHandler = (event: MessageEvent) => {
|
||||
const message = event.data
|
||||
|
||||
if (message.type === "enhancedPrompt") {
|
||||
if (message.type === "pastedImageSaved") {
|
||||
// Handle response from backend after saving pasted image
|
||||
if (message.requestId && pendingImageUploadsRef.current.has(message.requestId)) {
|
||||
// Remove from pending uploads
|
||||
pendingImageUploadsRef.current.delete(message.requestId)
|
||||
|
||||
if (message.imageUri && !message.error) {
|
||||
// Add the file URI to selected images (never base64)
|
||||
setSelectedImages((prevImages) =>
|
||||
[...prevImages, message.imageUri].slice(0, MAX_IMAGES_PER_MESSAGE),
|
||||
)
|
||||
} else {
|
||||
console.error("Failed to save pasted image:", message.error)
|
||||
// Do not fallback to base64 to ensure it is never rendered
|
||||
}
|
||||
}
|
||||
} else if (message.type === "enhancedPrompt") {
|
||||
if (message.text && textAreaRef.current) {
|
||||
try {
|
||||
// Use execCommand to replace text while preserving undo history
|
||||
|
|
@ -194,7 +210,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
|
||||
window.addEventListener("message", messageHandler)
|
||||
return () => window.removeEventListener("message", messageHandler)
|
||||
}, [setInputValue, searchRequestId, inputValue])
|
||||
}, [setInputValue, searchRequestId, inputValue, setSelectedImages])
|
||||
|
||||
const [isDraggingOver, setIsDraggingOver] = useState(false)
|
||||
const [textAreaBaseHeight, setTextAreaBaseHeight] = useState<number | undefined>(undefined)
|
||||
|
|
@ -630,6 +646,10 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
setIsFocused(false)
|
||||
}, [isMouseDownOnMenu])
|
||||
|
||||
// Track pending image upload request IDs only (never store base64)
|
||||
// This ensures the frontend never retains image data URLs
|
||||
const pendingImageUploadsRef = useRef<Set<string>>(new Set())
|
||||
|
||||
const handlePaste = useCallback(
|
||||
async (e: React.ClipboardEvent) => {
|
||||
const items = e.clipboardData.items
|
||||
|
|
@ -699,13 +719,26 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
const dataUrls = imageDataArray.filter((dataUrl): dataUrl is string => dataUrl !== null)
|
||||
|
||||
if (dataUrls.length > 0) {
|
||||
setSelectedImages((prevImages) => [...prevImages, ...dataUrls].slice(0, MAX_IMAGES_PER_MESSAGE))
|
||||
// Process each image: send to backend to save as temp file
|
||||
for (const dataUrl of dataUrls) {
|
||||
const requestId = Math.random().toString(36).substring(2, 9)
|
||||
|
||||
// Track request ID only; never store base64
|
||||
pendingImageUploadsRef.current.add(requestId)
|
||||
|
||||
// Send to backend to save as temporary file
|
||||
vscode.postMessage({
|
||||
type: "savePastedImage",
|
||||
dataUri: dataUrl,
|
||||
requestId: requestId,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
console.warn(t("chat:noValidImages"))
|
||||
}
|
||||
}
|
||||
},
|
||||
[shouldDisableImages, setSelectedImages, cursorPosition, setInputValue, inputValue, t],
|
||||
[shouldDisableImages, cursorPosition, setInputValue, inputValue, t],
|
||||
)
|
||||
|
||||
const handleMenuMouseDown = useCallback(() => {
|
||||
|
|
@ -851,12 +884,19 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
const dataUrls = imageDataArray.filter((dataUrl): dataUrl is string => dataUrl !== null)
|
||||
|
||||
if (dataUrls.length > 0) {
|
||||
setSelectedImages((prevImages) =>
|
||||
[...prevImages, ...dataUrls].slice(0, MAX_IMAGES_PER_MESSAGE),
|
||||
)
|
||||
// Process each dropped image: send to backend to save as temp file
|
||||
for (const dataUrl of dataUrls) {
|
||||
const requestId = Math.random().toString(36).substring(2, 9)
|
||||
|
||||
if (typeof vscode !== "undefined") {
|
||||
vscode.postMessage({ type: "draggedImages", dataUrls: dataUrls })
|
||||
// Track request ID only; never store base64
|
||||
pendingImageUploadsRef.current.add(requestId)
|
||||
|
||||
// Send to backend to save as temporary file
|
||||
vscode.postMessage({
|
||||
type: "savePastedImage",
|
||||
dataUri: dataUrl,
|
||||
requestId: requestId,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
console.warn(t("chat:noValidImages"))
|
||||
|
|
@ -872,7 +912,6 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
setCursorPosition,
|
||||
setIntendedCursorPosition,
|
||||
shouldDisableImages,
|
||||
setSelectedImages,
|
||||
t,
|
||||
],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -663,7 +663,10 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
}
|
||||
|
||||
setInputValue(newValue)
|
||||
setSelectedImages([...selectedImages, ...images])
|
||||
setSelectedImages([
|
||||
...selectedImages,
|
||||
...(images || []).filter((i) => typeof i === "string" && !i.startsWith("data:")),
|
||||
])
|
||||
},
|
||||
[inputValue, selectedImages],
|
||||
)
|
||||
|
|
@ -798,7 +801,11 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
// When context is "edit", ChatRow will handle the images
|
||||
if (message.context !== "edit") {
|
||||
setSelectedImages((prevImages: string[]) =>
|
||||
appendImages(prevImages, message.images, MAX_IMAGES_PER_MESSAGE),
|
||||
appendImages(
|
||||
prevImages,
|
||||
(message.images || []).filter((i) => typeof i === "string" && !i.startsWith("data:")),
|
||||
MAX_IMAGES_PER_MESSAGE,
|
||||
),
|
||||
)
|
||||
}
|
||||
break
|
||||
|
|
|
|||
|
|
@ -36,6 +36,9 @@ const Thumbnails = ({ images, style, setImages, onHeightChange }: ThumbnailsProp
|
|||
vscode.postMessage({ type: "openImage", text: image })
|
||||
}
|
||||
|
||||
// Never render base64 data URIs
|
||||
const safeImages = images.filter((img) => typeof img === "string" && !img.startsWith("data:"))
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
|
|
@ -47,7 +50,7 @@ const Thumbnails = ({ images, style, setImages, onHeightChange }: ThumbnailsProp
|
|||
rowGap: 3,
|
||||
...style,
|
||||
}}>
|
||||
{images.map((image, index) => (
|
||||
{safeImages.map((image, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{ position: "relative" }}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
import React from "react"
|
||||
import { render, fireEvent, screen } from "@src/utils/test-utils"
|
||||
|
||||
// Mock vscode.postMessage
|
||||
vi.mock("@src/utils/vscode", () => ({
|
||||
vscode: {
|
||||
postMessage: vi.fn(),
|
||||
},
|
||||
}))
|
||||
import { vscode } from "@src/utils/vscode"
|
||||
|
||||
import Thumbnails from "../Thumbnails"
|
||||
|
||||
describe("Thumbnails", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it("filters out base64 data URIs and renders only safe image URIs", () => {
|
||||
const images = [
|
||||
"data:image/png;base64,AAAA", // should be filtered
|
||||
"file:///tmp/saved-image-1.png",
|
||||
"https://example.com/image-2.webp",
|
||||
]
|
||||
|
||||
render(<Thumbnails images={images} />)
|
||||
|
||||
const imgs = screen.getAllByRole("img") as HTMLImageElement[]
|
||||
expect(imgs.length).toBe(2)
|
||||
// Ensure no rendered src starts with data:
|
||||
for (const img of imgs) {
|
||||
const src = img.getAttribute("src") || ""
|
||||
expect(src.startsWith("data:")).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it("posts openImage with clicked image URI (never base64)", () => {
|
||||
const images = ["data:image/png;base64,BBBB", "file:///tmp/saved-image.png"]
|
||||
render(<Thumbnails images={images} />)
|
||||
|
||||
const imgs = screen.getAllByRole("img") as HTMLImageElement[]
|
||||
// Only the safe one should render
|
||||
expect(imgs.length).toBe(1)
|
||||
|
||||
const safeImg = imgs[0]
|
||||
const src = safeImg.getAttribute("src") || ""
|
||||
expect(src.startsWith("data:")).toBe(false)
|
||||
|
||||
fireEvent.click(safeImg)
|
||||
|
||||
expect(vscode.postMessage).toHaveBeenCalledWith({
|
||||
type: "openImage",
|
||||
text: src,
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue