From b1f9380c3067190522f771910883fd9077326ecd Mon Sep 17 00:00:00 2001 From: Eduard Ruzga Date: Sun, 5 Jan 2025 13:56:02 +0200 Subject: [PATCH] fix: introduce our own cors proxy for git import to fix 403 errors on isometric git cors proxy (#924) * Exploration of improving git import * Fix our own git proxy * Clean out file counting for progress, does not seem to work well anyways --- app/components/chat/GitCloneButton.tsx | 91 +++++++----- app/components/git/GitUrlImport.client.tsx | 65 +++++---- app/components/ui/LoadingOverlay.tsx | 22 ++- app/lib/hooks/useGit.ts | 156 ++++++++++++--------- app/routes/api.git-proxy.$.ts | 65 +++++++++ 5 files changed, 264 insertions(+), 135 deletions(-) create mode 100644 app/routes/api.git-proxy.$.ts diff --git a/app/components/chat/GitCloneButton.tsx b/app/components/chat/GitCloneButton.tsx index 4fe4c55e..376d59d6 100644 --- a/app/components/chat/GitCloneButton.tsx +++ b/app/components/chat/GitCloneButton.tsx @@ -3,6 +3,9 @@ import { useGit } from '~/lib/hooks/useGit'; import type { Message } from 'ai'; import { detectProjectCommands, createCommandsMessage } from '~/utils/projectCommands'; import { generateId } from '~/utils/fileUtils'; +import { useState } from 'react'; +import { toast } from 'react-toastify'; +import { LoadingOverlay } from '~/components/ui/LoadingOverlay'; const IGNORE_PATTERNS = [ 'node_modules/**', @@ -37,6 +40,8 @@ interface GitCloneButtonProps { export default function GitCloneButton({ importChat }: GitCloneButtonProps) { const { ready, gitClone } = useGit(); + const [loading, setLoading] = useState(false); + const onClick = async (_e: any) => { if (!ready) { return; @@ -45,33 +50,34 @@ export default function GitCloneButton({ importChat }: GitCloneButtonProps) { const repoUrl = prompt('Enter the Git url'); if (repoUrl) { - const { workdir, data } = await gitClone(repoUrl); + setLoading(true); - if (importChat) { - const filePaths = Object.keys(data).filter((filePath) => !ig.ignores(filePath)); - console.log(filePaths); + try { + const { workdir, data } = await gitClone(repoUrl); - const textDecoder = new TextDecoder('utf-8'); + if (importChat) { + const filePaths = Object.keys(data).filter((filePath) => !ig.ignores(filePath)); + console.log(filePaths); - // Convert files to common format for command detection - const fileContents = filePaths - .map((filePath) => { - const { data: content, encoding } = data[filePath]; - return { - path: filePath, - content: encoding === 'utf8' ? content : content instanceof Uint8Array ? textDecoder.decode(content) : '', - }; - }) - .filter((f) => f.content); + const textDecoder = new TextDecoder('utf-8'); - // Detect and create commands message - const commands = await detectProjectCommands(fileContents); - const commandsMessage = createCommandsMessage(commands); + const fileContents = filePaths + .map((filePath) => { + const { data: content, encoding } = data[filePath]; + return { + path: filePath, + content: + encoding === 'utf8' ? content : content instanceof Uint8Array ? textDecoder.decode(content) : '', + }; + }) + .filter((f) => f.content); - // Create files message - const filesMessage: Message = { - role: 'assistant', - content: `Cloning the repo ${repoUrl} into ${workdir} + const commands = await detectProjectCommands(fileContents); + const commandsMessage = createCommandsMessage(commands); + + const filesMessage: Message = { + role: 'assistant', + content: `Cloning the repo ${repoUrl} into ${workdir} ${fileContents .map( @@ -82,29 +88,38 @@ ${file.content} ) .join('\n')} `, - id: generateId(), - createdAt: new Date(), - }; + id: generateId(), + createdAt: new Date(), + }; - const messages = [filesMessage]; + const messages = [filesMessage]; - if (commandsMessage) { - messages.push(commandsMessage); + if (commandsMessage) { + messages.push(commandsMessage); + } + + await importChat(`Git Project:${repoUrl.split('/').slice(-1)[0]}`, messages); } - - await importChat(`Git Project:${repoUrl.split('/').slice(-1)[0]}`, messages); + } catch (error) { + console.error('Error during import:', error); + toast.error('Failed to import repository'); + } finally { + setLoading(false); } } }; return ( - + <> + + {loading && } + ); } diff --git a/app/components/git/GitUrlImport.client.tsx b/app/components/git/GitUrlImport.client.tsx index c2c949ec..fe8b346b 100644 --- a/app/components/git/GitUrlImport.client.tsx +++ b/app/components/git/GitUrlImport.client.tsx @@ -49,33 +49,32 @@ export function GitUrlImport() { if (repoUrl) { const ig = ignore().add(IGNORE_PATTERNS); - const { workdir, data } = await gitClone(repoUrl); - if (importChat) { - const filePaths = Object.keys(data).filter((filePath) => !ig.ignores(filePath)); + try { + const { workdir, data } = await gitClone(repoUrl); - const textDecoder = new TextDecoder('utf-8'); + if (importChat) { + const filePaths = Object.keys(data).filter((filePath) => !ig.ignores(filePath)); + const textDecoder = new TextDecoder('utf-8'); - // Convert files to common format for command detection - const fileContents = filePaths - .map((filePath) => { - const { data: content, encoding } = data[filePath]; - return { - path: filePath, - content: encoding === 'utf8' ? content : content instanceof Uint8Array ? textDecoder.decode(content) : '', - }; - }) - .filter((f) => f.content); + const fileContents = filePaths + .map((filePath) => { + const { data: content, encoding } = data[filePath]; + return { + path: filePath, + content: + encoding === 'utf8' ? content : content instanceof Uint8Array ? textDecoder.decode(content) : '', + }; + }) + .filter((f) => f.content); - // Detect and create commands message - const commands = await detectProjectCommands(fileContents); - const commandsMessage = createCommandsMessage(commands); + const commands = await detectProjectCommands(fileContents); + const commandsMessage = createCommandsMessage(commands); - // Create files message - const filesMessage: Message = { - role: 'assistant', - content: `Cloning the repo ${repoUrl} into ${workdir} - + const filesMessage: Message = { + role: 'assistant', + content: `Cloning the repo ${repoUrl} into ${workdir} + ${fileContents .map( (file) => @@ -85,17 +84,25 @@ ${file.content} ) .join('\n')} `, - id: generateId(), - createdAt: new Date(), - }; + id: generateId(), + createdAt: new Date(), + }; - const messages = [filesMessage]; + const messages = [filesMessage]; - if (commandsMessage) { - messages.push(commandsMessage); + if (commandsMessage) { + messages.push(commandsMessage); + } + + await importChat(`Git Project:${repoUrl.split('/').slice(-1)[0]}`, messages); } + } catch (error) { + console.error('Error during import:', error); + toast.error('Failed to import repository'); + setLoading(false); + window.location.href = '/'; - await importChat(`Git Project:${repoUrl.split('/').slice(-1)[0]}`, messages); + return; } } }; diff --git a/app/components/ui/LoadingOverlay.tsx b/app/components/ui/LoadingOverlay.tsx index 6c69798f..2ade83b0 100644 --- a/app/components/ui/LoadingOverlay.tsx +++ b/app/components/ui/LoadingOverlay.tsx @@ -1,13 +1,31 @@ -export const LoadingOverlay = ({ message = 'Loading...' }) => { +export const LoadingOverlay = ({ + message = 'Loading...', + progress, + progressText, +}: { + message?: string; + progress?: number; + progressText?: string; +}) => { return (
- {/* Loading content */}

{message}

+ {progress !== undefined && ( +
+
+
+
+ {progressText &&

{progressText}

} +
+ )}
); diff --git a/app/lib/hooks/useGit.ts b/app/lib/hooks/useGit.ts index 3c8c61bb..cfa5027b 100644 --- a/app/lib/hooks/useGit.ts +++ b/app/lib/hooks/useGit.ts @@ -49,50 +49,54 @@ export function useGit() { } fileData.current = {}; - await git.clone({ - fs, - http, - dir: webcontainer.workdir, - url, - depth: 1, - singleBranch: true, - corsProxy: 'https://cors.isomorphic-git.org', - onAuth: (url) => { - // let domain=url.split("/")[2] - let auth = lookupSavedPassword(url); + try { + await git.clone({ + fs, + http, + dir: webcontainer.workdir, + url, + depth: 1, + singleBranch: true, + corsProxy: '/api/git-proxy', + onAuth: (url) => { + let auth = lookupSavedPassword(url); - if (auth) { - return auth; - } + if (auth) { + return auth; + } - if (confirm('This repo is password protected. Ready to enter a username & password?')) { - auth = { - username: prompt('Enter username'), - password: prompt('Enter password'), - }; - return auth; - } else { - return { cancel: true }; - } - }, - onAuthFailure: (url, _auth) => { - toast.error(`Error Authenticating with ${url.split('/')[2]}`); - }, - onAuthSuccess: (url, auth) => { - saveGitAuth(url, auth); - }, - }); + if (confirm('This repo is password protected. Ready to enter a username & password?')) { + auth = { + username: prompt('Enter username'), + password: prompt('Enter password'), + }; + return auth; + } else { + return { cancel: true }; + } + }, + onAuthFailure: (url, _auth) => { + toast.error(`Error Authenticating with ${url.split('/')[2]}`); + }, + onAuthSuccess: (url, auth) => { + saveGitAuth(url, auth); + }, + }); - const data: Record = {}; + const data: Record = {}; - for (const [key, value] of Object.entries(fileData.current)) { - data[key] = value; + for (const [key, value] of Object.entries(fileData.current)) { + data[key] = value; + } + + return { workdir: webcontainer.workdir, data }; + } catch (error) { + console.error('Git clone error:', error); + throw error; } - - return { workdir: webcontainer.workdir, data }; }, - [webcontainer], + [webcontainer, fs, ready], ); return { ready, gitClone }; @@ -104,55 +108,86 @@ const getFs = ( ) => ({ promises: { readFile: async (path: string, options: any) => { - const encoding = options.encoding; + const encoding = options?.encoding; const relativePath = pathUtils.relative(webcontainer.workdir, path); - console.log('readFile', relativePath, encoding); - return await webcontainer.fs.readFile(relativePath, encoding); + try { + const result = await webcontainer.fs.readFile(relativePath, encoding); + + return result; + } catch (error) { + throw error; + } }, writeFile: async (path: string, data: any, options: any) => { const encoding = options.encoding; const relativePath = pathUtils.relative(webcontainer.workdir, path); - console.log('writeFile', { relativePath, data, encoding }); if (record.current) { record.current[relativePath] = { data, encoding }; } - return await webcontainer.fs.writeFile(relativePath, data, { ...options, encoding }); + try { + const result = await webcontainer.fs.writeFile(relativePath, data, { ...options, encoding }); + + return result; + } catch (error) { + throw error; + } }, mkdir: async (path: string, options: any) => { const relativePath = pathUtils.relative(webcontainer.workdir, path); - console.log('mkdir', relativePath, options); - return await webcontainer.fs.mkdir(relativePath, { ...options, recursive: true }); + try { + const result = await webcontainer.fs.mkdir(relativePath, { ...options, recursive: true }); + + return result; + } catch (error) { + throw error; + } }, readdir: async (path: string, options: any) => { const relativePath = pathUtils.relative(webcontainer.workdir, path); - console.log('readdir', relativePath, options); - return await webcontainer.fs.readdir(relativePath, options); + try { + const result = await webcontainer.fs.readdir(relativePath, options); + + return result; + } catch (error) { + throw error; + } }, rm: async (path: string, options: any) => { const relativePath = pathUtils.relative(webcontainer.workdir, path); - console.log('rm', relativePath, options); - return await webcontainer.fs.rm(relativePath, { ...(options || {}) }); + try { + const result = await webcontainer.fs.rm(relativePath, { ...(options || {}) }); + + return result; + } catch (error) { + throw error; + } }, rmdir: async (path: string, options: any) => { const relativePath = pathUtils.relative(webcontainer.workdir, path); - console.log('rmdir', relativePath, options); - return await webcontainer.fs.rm(relativePath, { recursive: true, ...options }); + try { + const result = await webcontainer.fs.rm(relativePath, { recursive: true, ...options }); + + return result; + } catch (error) { + throw error; + } }, - - // Mock implementations for missing functions unlink: async (path: string) => { - // unlink is just removing a single file const relativePath = pathUtils.relative(webcontainer.workdir, path); - return await webcontainer.fs.rm(relativePath, { recursive: false }); - }, + try { + return await webcontainer.fs.rm(relativePath, { recursive: false }); + } catch (error) { + throw error; + } + }, stat: async (path: string) => { try { const relativePath = pathUtils.relative(webcontainer.workdir, path); @@ -185,23 +220,12 @@ const getFs = ( throw err; } }, - lstat: async (path: string) => { - /* - * For basic usage, lstat can return the same as stat - * since we're not handling symbolic links - */ return await getFs(webcontainer, record).promises.stat(path); }, - readlink: async (path: string) => { - /* - * Since WebContainer doesn't support symlinks, - * we'll throw a "not a symbolic link" error - */ throw new Error(`EINVAL: invalid argument, readlink '${path}'`); }, - symlink: async (target: string, path: string) => { /* * Since WebContainer doesn't support symlinks, diff --git a/app/routes/api.git-proxy.$.ts b/app/routes/api.git-proxy.$.ts new file mode 100644 index 00000000..9e6cb3b1 --- /dev/null +++ b/app/routes/api.git-proxy.$.ts @@ -0,0 +1,65 @@ +import { json } from '@remix-run/cloudflare'; +import type { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/cloudflare'; + +// Handle all HTTP methods +export async function action({ request, params }: ActionFunctionArgs) { + return handleProxyRequest(request, params['*']); +} + +export async function loader({ request, params }: LoaderFunctionArgs) { + return handleProxyRequest(request, params['*']); +} + +async function handleProxyRequest(request: Request, path: string | undefined) { + try { + if (!path) { + return json({ error: 'Invalid proxy URL format' }, { status: 400 }); + } + + const url = new URL(request.url); + + // Reconstruct the target URL + const targetURL = `https://${path}${url.search}`; + + // Forward the request to the target URL + const response = await fetch(targetURL, { + method: request.method, + headers: { + ...Object.fromEntries(request.headers), + + // Override host header with the target host + host: new URL(targetURL).host, + }, + body: ['GET', 'HEAD'].includes(request.method) ? null : await request.arrayBuffer(), + }); + + // Create response with CORS headers + const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': '*', + }; + + // Handle preflight requests + if (request.method === 'OPTIONS') { + return new Response(null, { + headers: corsHeaders, + status: 204, + }); + } + + // Forward the response with CORS headers + const responseHeaders = new Headers(response.headers); + Object.entries(corsHeaders).forEach(([key, value]) => { + responseHeaders.set(key, value); + }); + + return new Response(response.body, { + status: response.status, + headers: responseHeaders, + }); + } catch (error) { + console.error('Git proxy error:', error); + return json({ error: 'Proxy error' }, { status: 500 }); + } +}