diff --git a/packages/bruno-app/src/components/Sidebar/ImportCollection/FileTab.js b/packages/bruno-app/src/components/Sidebar/ImportCollection/FileTab.js new file mode 100644 index 000000000..d29f8a7a1 --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/ImportCollection/FileTab.js @@ -0,0 +1,218 @@ +import React, { useState, useRef } from 'react'; +import { IconFileImport } from '@tabler/icons'; +import { toastError } from 'utils/common/error'; +import jsyaml from 'js-yaml'; +import { isPostmanCollection } from 'utils/importers/postman-collection'; +import { isInsomniaCollection } from 'utils/importers/insomnia-collection'; +import { isOpenApiSpec } from 'utils/importers/openapi-collection'; +import { isWSDLCollection } from 'utils/importers/wsdl-collection'; +import { isBrunoCollection } from 'utils/importers/bruno-collection'; +import { isOpenCollection } from 'utils/importers/opencollection'; +import { useTheme } from 'providers/Theme'; + +const convertFileToObject = async (file) => { + const text = await file.text(); + + // Handle WSDL files - return as plain text + if (file.name.endsWith('.wsdl') || file.type === 'text/xml' || file.type === 'application/xml') { + return text; + } + + try { + if (file.type === 'application/json' || file.name.endsWith('.json')) { + return JSON.parse(text); + } + + const parsed = jsyaml.load(text); + if (typeof parsed !== 'object' || parsed === null) { + throw new Error(); + } + return parsed; + } catch { + throw new Error('Failed to parse the file – ensure it is valid JSON or YAML'); + } +}; + +const FileTab = ({ + setIsLoading, + handleSubmit, + setErrorMessage +}) => { + const [dragActive, setDragActive] = useState(false); + const fileInputRef = useRef(null); + const { theme } = useTheme(); + + const acceptedFileTypes = [ + '.json', + '.yaml', + '.yml', + '.wsdl', + '.zip', + 'application/json', + 'application/yaml', + 'application/x-yaml', + 'application/zip', + 'application/x-zip-compressed', + 'text/xml', + 'application/xml' + ]; + + const handleDrag = (e) => { + e.preventDefault(); + e.stopPropagation(); + + if (e.dataTransfer) { + e.dataTransfer.dropEffect = 'copy'; + } + + if (e.type === 'dragenter' || e.type === 'dragover') { + setDragActive(true); + } else if (e.type === 'dragleave') { + setDragActive(false); + } + }; + + const processZipFile = async (zipFile) => { + setIsLoading(true); + try { + const filePath = window.ipcRenderer.getFilePath(zipFile); + const collectionName = zipFile.name.replace(/\.zip$/i, ''); + await handleSubmit({ rawData: { zipFilePath: filePath, collectionName }, type: 'bruno-zip' }); + } catch (err) { + toastError(err, 'Import ZIP file failed'); + } finally { + setIsLoading(false); + } + }; + + const processFile = async (file) => { + setIsLoading(true); + try { + const data = await convertFileToObject(file); + + if (!data) { + throw new Error('Failed to parse file content'); + } + + let type = null; + + if (isOpenApiSpec(data)) { + type = 'openapi'; + } else if (isWSDLCollection(data)) { + type = 'wsdl'; + } else if (isPostmanCollection(data)) { + type = 'postman'; + } else if (isInsomniaCollection(data)) { + type = 'insomnia'; + } else if (isOpenCollection(data)) { + type = 'opencollection'; + } else if (isBrunoCollection(data)) { + type = 'bruno'; + } else { + throw new Error('Unsupported collection format'); + } + + await handleSubmit({ rawData: data, type }); + } catch (err) { + toastError(err, 'Import collection failed'); + } finally { + setIsLoading(false); + } + }; + + const processFiles = async (files) => { + setErrorMessage(''); + + const fileArray = Array.from(files); + const zipFiles = fileArray.filter((file) => file.name.endsWith('.zip')); + + // If both ZIP and non-ZIP files are selected, show error + if (zipFiles.length && (fileArray.length - zipFiles.length > 0)) { + setErrorMessage('Cannot mix ZIP files with other file types. Please select either a single ZIP file OR collection files (JSON/YAML)'); + return; + } + + if (zipFiles.length > 1) { + setErrorMessage('Multiple ZIP files selected. Please select only one ZIP file at a time for import.'); + return; + } + + if (zipFiles.length) { + await processZipFile(zipFiles[0]); + return; + } + + if (fileArray.length > 0) { + await processFile(fileArray[0]); + } + }; + + const handleDrop = async (e) => { + e.preventDefault(); + e.stopPropagation(); + setDragActive(false); + + if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { + await processFiles(e.dataTransfer.files); + } + }; + + const handleBrowseFiles = () => { + setErrorMessage(''); + fileInputRef.current.click(); + }; + + const handleFileInputChange = async (e) => { + if (e.target.files && e.target.files.length > 0) { + await processFiles(e.target.files); + e.target.value = ''; + } + }; + + return ( +
+
+
+ + +

+ Drop file to import or{' '} + +

+

+ Supports Bruno, OpenCollection, Postman, Insomnia, OpenAPI v3, WSDL, and ZIP formats +

+
+
+
+ ); +}; + +export default FileTab; diff --git a/packages/bruno-app/src/components/Sidebar/ImportCollection/index.js b/packages/bruno-app/src/components/Sidebar/ImportCollection/index.js index 9ad1d139f..a1bda3e01 100644 --- a/packages/bruno-app/src/components/Sidebar/ImportCollection/index.js +++ b/packages/bruno-app/src/components/Sidebar/ImportCollection/index.js @@ -1,175 +1,53 @@ -import React, { useState, useEffect, useRef } from 'react'; -import { IconFileImport } from '@tabler/icons'; -import { toastError } from 'utils/common/error'; +import React, { useState } from 'react'; +import { IconX } from '@tabler/icons'; import Modal from 'components/Modal'; -import jsyaml from 'js-yaml'; -import { isPostmanCollection } from 'utils/importers/postman-collection'; -import { isInsomniaCollection } from 'utils/importers/insomnia-collection'; -import { isOpenApiSpec } from 'utils/importers/openapi-collection'; -import { isWSDLCollection } from 'utils/importers/wsdl-collection'; -import { isBrunoCollection } from 'utils/importers/bruno-collection'; -import { isOpenCollection } from 'utils/importers/opencollection'; +import FileTab from './FileTab'; import FullscreenLoader from './FullscreenLoader/index'; import { useTheme } from 'providers/Theme'; -const convertFileToObject = async (file) => { - const text = await file.text(); - - // Handle WSDL files - return as plain text - if (file.name.endsWith('.wsdl') || file.type === 'text/xml' || file.type === 'application/xml') { - return text; - } - - try { - if (file.type === 'application/json' || file.name.endsWith('.json')) { - return JSON.parse(text); - } - - const parsed = jsyaml.load(text); - if (typeof parsed !== 'object' || parsed === null) { - throw new Error(); - } - return parsed; - } catch { - throw new Error('Failed to parse the file – ensure it is valid JSON or YAML'); - } -}; - const ImportCollection = ({ onClose, handleSubmit }) => { const { theme } = useTheme(); const [isLoading, setIsLoading] = useState(false); - const [dragActive, setDragActive] = useState(false); - const fileInputRef = useRef(null); - - const handleDrag = (e) => { - e.preventDefault(); - e.stopPropagation(); - - if (e.dataTransfer) { - e.dataTransfer.dropEffect = 'copy'; - } - - if (e.type === 'dragenter' || e.type === 'dragover') { - setDragActive(true); - } else if (e.type === 'dragleave') { - setDragActive(false); - } - }; - - const processFile = async (file) => { - setIsLoading(true); - try { - const data = await convertFileToObject(file); - - if (!data) { - throw new Error('Failed to parse file content'); - } - - let type = null; - - if (isOpenApiSpec(data)) { - type = 'openapi'; - } else if (isWSDLCollection(data)) { - type = 'wsdl'; - } else if (isPostmanCollection(data)) { - type = 'postman'; - } else if (isInsomniaCollection(data)) { - type = 'insomnia'; - } else if (isOpenCollection(data)) { - type = 'opencollection'; - } else if (isBrunoCollection(data)) { - type = 'bruno'; - } else { - throw new Error('Unsupported collection format'); - } - - handleSubmit({ rawData: data, type }); - } catch (err) { - toastError(err, 'Import collection failed'); - } finally { - setIsLoading(false); - } - }; - - const handleDrop = async (e) => { - e.preventDefault(); - e.stopPropagation(); - setDragActive(false); - - if (e.dataTransfer.files && e.dataTransfer.files[0]) { - await processFile(e.dataTransfer.files[0]); - } - }; - - const handleBrowseFiles = () => { - fileInputRef.current.click(); - }; - - const handleFileInputChange = async (e) => { - if (e.target.files && e.target.files[0]) { - await processFile(e.target.files[0]); - } - }; + const [errorMessage, setErrorMessage] = useState(''); if (isLoading) { return ; } - const acceptedFileTypes = [ - '.json', - '.yaml', - '.yml', - '.wsdl', - 'application/json', - 'application/yaml', - 'application/x-yaml', - 'text/xml', - 'application/xml' - ]; - return (
-
-

Import from file

+ {errorMessage && (
-
- - -

- Drop file to import or{' '} - -

-

- Supports Bruno, OpenCollection, Postman, Insomnia, OpenAPI v3, and WSDL formats -

+
+
+ {errorMessage} +
+
setErrorMessage('')} + style={{ color: theme.status?.danger?.text || '#dc2626' }} + > + +
-
+ )} + +
); diff --git a/packages/bruno-app/src/components/Sidebar/ImportCollectionLocation/index.js b/packages/bruno-app/src/components/Sidebar/ImportCollectionLocation/index.js index 094c2d9f6..b8e18e3db 100644 --- a/packages/bruno-app/src/components/Sidebar/ImportCollectionLocation/index.js +++ b/packages/bruno-app/src/components/Sidebar/ImportCollectionLocation/index.js @@ -43,6 +43,8 @@ const getCollectionName = (format, rawData) => { return rawData.info?.name || 'OpenCollection'; case 'wsdl': return 'WSDL Collection'; + case 'bruno-zip': + return rawData.collectionName || 'Bruno Collection'; default: return 'Collection'; } @@ -72,6 +74,10 @@ const convertCollection = async (format, rawData, groupingType) => { case 'opencollection': collection = await processOpenCollection(rawData); break; + case 'bruno-zip': + // ZIP doesn't need conversion + collection = rawData; + break; default: throw new Error('Unknown collection format'); } @@ -96,6 +102,7 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, rawData, format }) => const [collectionFormat, setCollectionFormat] = useState(DEFAULT_COLLECTION_FORMAT); const dropdownTippyRef = useRef(); const isOpenApi = format === 'openapi'; + const isZipImport = format === 'bruno-zip'; const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces); const preferences = useSelector((state) => state.app.preferences); @@ -159,7 +166,19 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, rawData, format }) => } }, [inputRef]); - const onSubmit = () => formik.handleSubmit(); + const onSubmit = async () => { + if (isZipImport) { + const errors = await formik.validateForm(); + if (Object.keys(errors).length > 0) { + formik.setTouched({ collectionLocation: true }); + return; + } + const collectionLocation = formik.values.collectionLocation; + handleSubmit(rawData, collectionLocation, { format: collectionFormat, isZipImport: true }); + } else { + formik.handleSubmit(); + } + }; return ( @@ -212,30 +231,32 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, rawData, format }) =>
-
- - -
+ {!isZipImport && ( +
+ + +
+ )} {isOpenApi && ( diff --git a/packages/bruno-app/src/components/Sidebar/Sections/CollectionsSection/index.js b/packages/bruno-app/src/components/Sidebar/Sections/CollectionsSection/index.js index 2749157aa..d3fec1217 100644 --- a/packages/bruno-app/src/components/Sidebar/Sections/CollectionsSection/index.js +++ b/packages/bruno-app/src/components/Sidebar/Sections/CollectionsSection/index.js @@ -15,7 +15,7 @@ import { IconTerminal2 } from '@tabler/icons'; -import { importCollection, openCollection } from 'providers/ReduxStore/slices/collections/actions'; +import { importCollection, openCollection, importCollectionFromZip } from 'providers/ReduxStore/slices/collections/actions'; import { sortCollections } from 'providers/ReduxStore/slices/collections/index'; import { normalizePath } from 'utils/common/path'; @@ -52,14 +52,18 @@ const CollectionsSection = () => { ); }, [activeWorkspace, collections]); - const handleImportCollection = ({ rawData, type }) => { + const handleImportCollection = ({ rawData, type, ...rest }) => { setImportCollectionModalOpen(false); - setImportData({ rawData, type }); + setImportData({ rawData, type, ...rest }); setImportCollectionLocationModalOpen(true); }; const handleImportCollectionLocation = (convertedCollection, collectionLocation, options = {}) => { - dispatch(importCollection(convertedCollection, collectionLocation, options)) + const importAction = options.isZipImport + ? importCollectionFromZip(convertedCollection.zipFilePath, collectionLocation) + : importCollection(convertedCollection, collectionLocation, options); + + dispatch(importAction) .then(() => { setImportCollectionLocationModalOpen(false); setImportData(null); diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/index.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/index.js index b8b688922..83c2ac8b2 100644 --- a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/index.js +++ b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/index.js @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import { useSelector, useDispatch } from 'react-redux'; import { IconPlus, IconFolder, IconDownload } from '@tabler/icons'; -import { importCollection, openCollection } from 'providers/ReduxStore/slices/collections/actions'; +import { importCollection, openCollection, importCollectionFromZip } from 'providers/ReduxStore/slices/collections/actions'; import toast from 'react-hot-toast'; import CreateCollection from 'components/Sidebar/CreateCollection'; import ImportCollection from 'components/Sidebar/ImportCollection'; @@ -51,14 +51,18 @@ const WorkspaceOverview = ({ workspace }) => { setImportCollectionModalOpen(true); }; - const handleImportCollectionSubmit = ({ rawData, type }) => { + const handleImportCollectionSubmit = ({ rawData, type, ...rest }) => { setImportCollectionModalOpen(false); - setImportData({ rawData, type }); + setImportData({ rawData, type, ...rest }); setImportCollectionLocationModalOpen(true); }; const handleImportCollectionLocation = (convertedCollection, collectionLocation, options = {}) => { - dispatch(importCollection(convertedCollection, collectionLocation, options)) + const importAction = options.isZipImport + ? importCollectionFromZip(convertedCollection.zipFilePath, collectionLocation) + : importCollection(convertedCollection, collectionLocation, options); + + dispatch(importAction) .then(() => { setImportCollectionLocationModalOpen(false); setImportData(null); diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js index a1f1ce16a..2aa6144ef 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js @@ -2663,6 +2663,24 @@ export const importCollection = (collection, collectionLocation, options = {}) = }); }; +export const importCollectionFromZip = (zipFilePath, collectionLocation) => async (dispatch, getState) => { + const { ipcRenderer } = window; + const state = getState(); + const activeWorkspace = state.workspaces.workspaces.find((w) => w.uid === state.workspaces.activeWorkspaceUid); + + const collectionPath = await ipcRenderer.invoke('renderer:import-collection-zip', zipFilePath, collectionLocation); + + if (activeWorkspace && activeWorkspace.pathname && activeWorkspace.type !== 'default') { + const collectionName = path.basename(collectionPath); + await ipcRenderer.invoke('renderer:add-collection-to-workspace', activeWorkspace.pathname, { + name: collectionName, + path: collectionPath + }); + } + + return collectionPath; +}; + export const moveCollectionAndPersist = ({ draggedItem, targetItem }) => (dispatch, getState) => { diff --git a/packages/bruno-electron/src/ipc/collection.js b/packages/bruno-electron/src/ipc/collection.js index 2fa8656dc..c03d0771d 100644 --- a/packages/bruno-electron/src/ipc/collection.js +++ b/packages/bruno-electron/src/ipc/collection.js @@ -4,6 +4,7 @@ const fsExtra = require('fs-extra'); const os = require('os'); const path = require('path'); const archiver = require('archiver'); +const extractZip = require('extract-zip'); const { ipcMain, shell, dialog, app } = require('electron'); const { parseRequest, @@ -2050,6 +2051,115 @@ const registerRendererEventHandlers = (mainWindow, watcher) => { throw error; } }); + + ipcMain.handle('renderer:import-collection-zip', async (event, zipFilePath, collectionLocation) => { + try { + if (!fs.existsSync(zipFilePath)) { + throw new Error('ZIP file does not exist'); + } + + if (!collectionLocation || !fs.existsSync(collectionLocation)) { + throw new Error('Collection location does not exist'); + } + + const tempDir = path.join(os.tmpdir(), `bruno_zip_import_${Date.now()}`); + await fsExtra.ensureDir(tempDir); + + // Validates that no symlinks point outside the base directory + const validateNoExternalSymlinks = (dir, baseDir) => { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + const stat = fs.lstatSync(fullPath); + + if (stat.isSymbolicLink()) { + const linkTarget = fs.readlinkSync(fullPath); + const resolvedTarget = path.resolve(path.dirname(fullPath), linkTarget); + if (!resolvedTarget.startsWith(baseDir + path.sep) && resolvedTarget !== baseDir) { + throw new Error(`Security error: Symlink "${entry.name}" points outside extraction directory`); + } + } + + if (stat.isDirectory() && !stat.isSymbolicLink()) { + validateNoExternalSymlinks(fullPath, baseDir); + } + } + }; + + try { + await extractZip(zipFilePath, { dir: tempDir }); + + validateNoExternalSymlinks(tempDir, tempDir); + + const extractedItems = fs.readdirSync(tempDir); + let collectionDir = tempDir; + + if (extractedItems.length === 1) { + const singleItem = path.join(tempDir, extractedItems[0]); + const singleItemStat = fs.lstatSync(singleItem); + if (singleItemStat.isDirectory() && !singleItemStat.isSymbolicLink()) { + collectionDir = singleItem; + } + } + + const brunoJsonPath = path.join(collectionDir, 'bruno.json'); + const openCollectionYmlPath = path.join(collectionDir, 'opencollection.yml'); + + if (!fs.existsSync(brunoJsonPath) && !fs.existsSync(openCollectionYmlPath)) { + throw new Error('Invalid collection: Neither bruno.json nor opencollection.yml found in the ZIP file'); + } + + // Ensure config files are not symlinks + if (fs.existsSync(brunoJsonPath) && fs.lstatSync(brunoJsonPath).isSymbolicLink()) { + throw new Error('Security error: bruno.json cannot be a symbolic link'); + } + if (fs.existsSync(openCollectionYmlPath) && fs.lstatSync(openCollectionYmlPath).isSymbolicLink()) { + throw new Error('Security error: opencollection.yml cannot be a symbolic link'); + } + + let collectionName = 'Imported Collection'; + if (fs.existsSync(openCollectionYmlPath)) { + try { + const content = fs.readFileSync(openCollectionYmlPath, 'utf8'); + const { brunoConfig } = parseCollection(content, { format: 'yml' }); + collectionName = brunoConfig?.name || collectionName; + } catch (e) { + console.error(`Error parsing opencollection.yml at ${openCollectionYmlPath}:`, e); + } + } else if (fs.existsSync(brunoJsonPath)) { + try { + const config = JSON.parse(fs.readFileSync(brunoJsonPath, 'utf8')); + collectionName = config.name || collectionName; + } catch (e) { + console.error(`Error parsing bruno.json at ${brunoJsonPath}:`, e); + } + } + + let sanitizedName = sanitizeName(collectionName); + if (!sanitizedName) { + sanitizedName = `untitled-${Date.now()}`; + } + let finalCollectionPath = path.join(collectionLocation, sanitizedName); + let counter = 1; + while (fs.existsSync(finalCollectionPath)) { + finalCollectionPath = path.join(collectionLocation, `${sanitizedName} (${counter})`); + counter++; + } + + await fsExtra.move(collectionDir, finalCollectionPath); + if (tempDir !== collectionDir) { + await fsExtra.remove(tempDir).catch(() => {}); + } + + return finalCollectionPath; + } catch (error) { + await fsExtra.remove(tempDir).catch(() => {}); + throw error; + } + } catch (error) { + throw error; + } + }); }; const registerMainEventHandlers = (mainWindow, watcher) => {