mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-15 20:01:28 +00:00
feat: add ZIP file import for collections (#7063)
* feat: add ZIP file import for collections
This commit is contained in:
@@ -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 (
|
||||
<div className="mb-4">
|
||||
<div
|
||||
onDragEnter={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
className={`
|
||||
border-2 border-dashed rounded-lg p-6 transition-colors duration-200
|
||||
${dragActive
|
||||
? 'border-blue-500 bg-blue-50 dark:border-blue-400 dark:bg-blue-900/20'
|
||||
: 'border-gray-200 dark:border-gray-700'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<IconFileImport
|
||||
size={28}
|
||||
className="text-gray-400 dark:text-gray-500 mb-3"
|
||||
/>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={handleFileInputChange}
|
||||
accept={acceptedFileTypes.join(',')}
|
||||
/>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 mb-2">
|
||||
Drop file to import or{' '}
|
||||
<button
|
||||
className="underline cursor-pointer"
|
||||
onClick={handleBrowseFiles}
|
||||
style={{ color: theme.textLink }}
|
||||
>
|
||||
choose a file
|
||||
</button>
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 text-center">
|
||||
Supports Bruno, OpenCollection, Postman, Insomnia, OpenAPI v3, WSDL, and ZIP formats
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileTab;
|
||||
@@ -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 <FullscreenLoader isLoading={isLoading} />;
|
||||
}
|
||||
|
||||
const acceptedFileTypes = [
|
||||
'.json',
|
||||
'.yaml',
|
||||
'.yml',
|
||||
'.wsdl',
|
||||
'application/json',
|
||||
'application/yaml',
|
||||
'application/x-yaml',
|
||||
'text/xml',
|
||||
'application/xml'
|
||||
];
|
||||
|
||||
return (
|
||||
<Modal size="sm" title="Import Collection" hideFooter={true} handleCancel={onClose} dataTestId="import-collection-modal">
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-4">
|
||||
<h3 className="font-medium text-gray-900 dark:text-gray-100 mb-2">Import from file</h3>
|
||||
{errorMessage && (
|
||||
<div
|
||||
onDragEnter={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
className={`
|
||||
border-2 border-dashed rounded-lg p-6 transition-colors duration-200
|
||||
${dragActive ? 'border-blue-500 bg-blue-50 dark:border-blue-400 dark:bg-blue-900/20' : 'border-gray-200 dark:border-gray-700'}
|
||||
`}
|
||||
className="mb-4 p-2 border rounded-md"
|
||||
style={{
|
||||
backgroundColor: theme.status?.danger?.background || '#fef2f2',
|
||||
borderColor: theme.status?.danger?.border || '#fecaca'
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<IconFileImport
|
||||
size={28}
|
||||
className="text-gray-400 dark:text-gray-500 mb-3"
|
||||
/>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={handleFileInputChange}
|
||||
accept={acceptedFileTypes.join(',')}
|
||||
/>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-2">
|
||||
Drop file to import or{' '}
|
||||
<button
|
||||
className="underline cursor-pointer"
|
||||
onClick={handleBrowseFiles}
|
||||
style={{ color: theme.textLink }}
|
||||
>
|
||||
choose a file
|
||||
</button>
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Supports Bruno, OpenCollection, Postman, Insomnia, OpenAPI v3, and WSDL formats
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<div
|
||||
className="text-xs flex-1"
|
||||
style={{ color: theme.status?.danger?.text || '#dc2626' }}
|
||||
>
|
||||
{errorMessage}
|
||||
</div>
|
||||
<div
|
||||
className="close-button flex items-center cursor-pointer"
|
||||
onClick={() => setErrorMessage('')}
|
||||
style={{ color: theme.status?.danger?.text || '#dc2626' }}
|
||||
>
|
||||
<IconX size={16} strokeWidth={1.5} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FileTab
|
||||
setIsLoading={setIsLoading}
|
||||
handleSubmit={handleSubmit}
|
||||
setErrorMessage={setErrorMessage}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<StyledWrapper>
|
||||
@@ -212,30 +231,32 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, rawData, format }) =>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<label htmlFor="format" className="flex items-center font-medium">
|
||||
File Format
|
||||
<Help width="300">
|
||||
<p>Choose the file format for storing requests in this collection.</p>
|
||||
<p className="mt-2">
|
||||
<strong>OpenCollection (YAML):</strong> Industry-standard YAML format (.yml files)
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
<strong>BRU:</strong> Bruno's native file format (.bru files)
|
||||
</p>
|
||||
</Help>
|
||||
</label>
|
||||
<select
|
||||
id="format"
|
||||
name="format"
|
||||
className="block textbox mt-2 w-full"
|
||||
value={collectionFormat}
|
||||
onChange={(e) => setCollectionFormat(e.target.value)}
|
||||
>
|
||||
<option value="yml">OpenCollection (YAML)</option>
|
||||
<option value="bru">BRU Format (.bru)</option>
|
||||
</select>
|
||||
</div>
|
||||
{!isZipImport && (
|
||||
<div className="mt-4">
|
||||
<label htmlFor="format" className="flex items-center font-medium">
|
||||
File Format
|
||||
<Help width="300">
|
||||
<p>Choose the file format for storing requests in this collection.</p>
|
||||
<p className="mt-2">
|
||||
<strong>OpenCollection (YAML):</strong> Industry-standard YAML format (.yml files)
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
<strong>BRU:</strong> Bruno's native file format (.bru files)
|
||||
</p>
|
||||
</Help>
|
||||
</label>
|
||||
<select
|
||||
id="format"
|
||||
name="format"
|
||||
className="block textbox mt-2 w-full"
|
||||
value={collectionFormat}
|
||||
onChange={(e) => setCollectionFormat(e.target.value)}
|
||||
>
|
||||
<option value="yml">OpenCollection (YAML)</option>
|
||||
<option value="bru">BRU Format (.bru)</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isOpenApi && (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user