Merge branch 'main' into fix/save-prompt-close-multi-tab

This commit is contained in:
Ricardo Silverio 2024-09-29 10:14:08 -03:00
commit e5c718f9f7
69 changed files with 876 additions and 178 deletions

View File

@ -55,6 +55,7 @@ if (!SERVER_RENDERED) {
'req.setMaxRedirects(maxRedirects)', 'req.setMaxRedirects(maxRedirects)',
'req.getTimeout()', 'req.getTimeout()',
'req.setTimeout(timeout)', 'req.setTimeout(timeout)',
'req.getExecutionMode()',
'bru', 'bru',
'bru.cwd()', 'bru.cwd()',
'bru.getEnvName(key)', 'bru.getEnvName(key)',

View File

@ -52,6 +52,15 @@ const AuthMode = ({ collection }) => {
> >
Basic Auth Basic Auth
</div> </div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('wsse');
}}
>
WSSE Auth
</div>
<div <div
className="dropdown-item" className="dropdown-item"
onClick={() => { onClick={() => {

View File

@ -0,0 +1,16 @@
import styled from 'styled-components';
const Wrapper = styled.div`
label {
font-size: 0.8125rem;
}
.single-line-editor-wrapper {
padding: 0.15rem 0.4rem;
border-radius: 3px;
border: solid 1px ${(props) => props.theme.input.border};
background-color: ${(props) => props.theme.input.bg};
}
`;
export default Wrapper;

View File

@ -0,0 +1,71 @@
import React from 'react';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import SingleLineEditor from 'components/SingleLineEditor';
import { updateCollectionAuth } from 'providers/ReduxStore/slices/collections';
import { saveCollectionRoot } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
const WsseAuth = ({ collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const wsseAuth = get(collection, 'root.request.auth.wsse', {});
const handleSave = () => dispatch(saveCollectionRoot(collection.uid));
const handleUserChange = (username) => {
dispatch(
updateCollectionAuth({
mode: 'wsse',
collectionUid: collection.uid,
content: {
username,
password: wsseAuth.password
}
})
);
};
const handlePasswordChange = (password) => {
dispatch(
updateCollectionAuth({
mode: 'wsse',
collectionUid: collection.uid,
content: {
username: wsseAuth.username,
password
}
})
);
};
return (
<StyledWrapper className="mt-2 w-full">
<label className="block font-medium mb-2">Username</label>
<div className="single-line-editor-wrapper mb-2">
<SingleLineEditor
value={wsseAuth.username || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleUserChange(val)}
collection={collection}
/>
</div>
<label className="block font-medium mb-2">Password</label>
<div className="single-line-editor-wrapper">
<SingleLineEditor
value={wsseAuth.password || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handlePasswordChange(val)}
collection={collection}
/>
</div>
</StyledWrapper>
);
};
export default WsseAuth;

View File

@ -6,6 +6,7 @@ import AwsV4Auth from './AwsV4Auth';
import BearerAuth from './BearerAuth'; import BearerAuth from './BearerAuth';
import BasicAuth from './BasicAuth'; import BasicAuth from './BasicAuth';
import DigestAuth from './DigestAuth'; import DigestAuth from './DigestAuth';
import WsseAuth from './WsseAuth';
import ApiKeyAuth from './ApiKeyAuth/'; import ApiKeyAuth from './ApiKeyAuth/';
import { saveCollectionRoot } from 'providers/ReduxStore/slices/collections/actions'; import { saveCollectionRoot } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper'; import StyledWrapper from './StyledWrapper';
@ -34,6 +35,9 @@ const Auth = ({ collection }) => {
case 'oauth2': { case 'oauth2': {
return <OAuth2 collection={collection} />; return <OAuth2 collection={collection} />;
} }
case 'wsse': {
return <WsseAuth collection={collection} />;
}
case 'apikey': { case 'apikey': {
return <ApiKeyAuth collection={collection} />; return <ApiKeyAuth collection={collection} />;
} }

View File

@ -83,7 +83,6 @@ const VarsTable = ({ collection, vars, varType }) => {
<td> <td>
<div className="flex items-center"> <div className="flex items-center">
<span>Value</span> <span>Value</span>
<InfoTip text="You can write any valid JS Template Literal here" infotipId="request-var" />
</div> </div>
</td> </td>
) : ( ) : (

View File

@ -88,7 +88,9 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
useEffect(() => { useEffect(() => {
if (formik.dirty) { if (formik.dirty) {
addButtonRef.current?.scrollIntoView({ behavior: 'smooth' }); // Smooth scrolling to the changed parameter is temporarily disabled
// due to UX issues when editing the first row in a long list of environment variables.
// addButtonRef.current?.scrollIntoView({ behavior: 'smooth' });
} }
}, [formik.values, formik.dirty]); }, [formik.values, formik.dirty]);

View File

@ -22,6 +22,9 @@ const RenameEnvironment = ({ onClose, environment, collection }) => {
.required('name is required') .required('name is required')
}), }),
onSubmit: (values) => { onSubmit: (values) => {
if (values.name === environment.name) {
return;
}
dispatch(renameEnvironment(values.name, environment.uid, collection.uid)) dispatch(renameEnvironment(values.name, environment.uid, collection.uid))
.then(() => { .then(() => {
toast.success('Environment renamed successfully'); toast.success('Environment renamed successfully');

View File

@ -82,7 +82,6 @@ const VarsTable = ({ folder, collection, vars, varType }) => {
<td> <td>
<div className="flex items-center"> <div className="flex items-center">
<span>Value</span> <span>Value</span>
<InfoTip text="You can write any valid JS Template Literal here" infotipId="request-var" />
</div> </div>
</td> </td>
) : ( ) : (

View File

@ -39,7 +39,7 @@ const Font = ({ close }) => {
<StyledWrapper> <StyledWrapper>
<div className="flex flex-row gap-2 w-full"> <div className="flex flex-row gap-2 w-full">
<div className="w-4/5"> <div className="w-4/5">
<label className="block font-medium">Code Editor Font</label> <label className="block">Code Editor Font</label>
<input <input
type="text" type="text"
className="block textbox mt-2 w-full" className="block textbox mt-2 w-full"
@ -52,7 +52,7 @@ const Font = ({ close }) => {
/> />
</div> </div>
<div className="w-1/5"> <div className="w-1/5">
<label className="block font-medium">Font Size</label> <label className="block">Font Size</label>
<input <input
type="number" type="number"
className="block textbox mt-2 w-full" className="block textbox mt-2 w-full"

View File

@ -0,0 +1,22 @@
import React from 'react';
import Font from './Font/index';
import Theme from './Theme/index';
const Display = ({ close }) => {
return (
<div className="flex flex-col my-2 gap-10 w-full">
<div className='w-full flex flex-col gap-2'>
<span>
Theme
</span>
<Theme close={close} />
</div>
<div className='h-[1px] bg-[#aaa5] w-full'></div>
<div className='w-fit flex flex-col gap-2'>
<Font close={close} />
</div>
</div>
);
};
export default Display;

View File

@ -100,7 +100,7 @@ const General = ({ close }) => {
return ( return (
<StyledWrapper> <StyledWrapper>
<form className="bruno-form" onSubmit={formik.handleSubmit}> <form className="bruno-form" onSubmit={formik.handleSubmit}>
<div className="flex items-center mt-2"> <div className="flex items-center my-2">
<input <input
id="sslVerification" id="sslVerification"
type="checkbox" type="checkbox"

View File

@ -113,7 +113,7 @@ const ProxySettings = ({ close }) => {
return ( return (
<StyledWrapper> <StyledWrapper>
<form className="bruno-form" onSubmit={formik.handleSubmit}> <form className="bruno-form" onSubmit={formik.handleSubmit}>
<div className="mb-3 flex items-center"> <div className="mb-3 flex items-center mt-2">
<label className="settings-label" htmlFor="protocol"> <label className="settings-label" htmlFor="protocol">
Mode Mode
</label> </label>

View File

@ -2,13 +2,12 @@ import styled from 'styled-components';
const StyledWrapper = styled.div` const StyledWrapper = styled.div`
div.tabs { div.tabs {
margin-top: -0.5rem;
div.tab { div.tab {
padding: 6px 0px; width: 100%;
min-width: 120px;
padding: 7px 10px;
border: none; border: none;
border-bottom: solid 2px transparent; border-bottom: solid 2px transparent;
margin-right: 1.25rem;
color: var(--color-tab-inactive); color: var(--color-tab-inactive);
cursor: pointer; cursor: pointer;
@ -22,8 +21,12 @@ const StyledWrapper = styled.div`
} }
&.active { &.active {
color: ${(props) => props.theme.tabs.active.color} !important; color: ${(props) => props.theme.sidebar.color} !important;
border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important; background: ${(props) => props.theme.sidebar.collection.item.bg};
&:hover {
background: ${(props) => props.theme.sidebar.collection.item.bg} !important;
}
} }
} }
} }

View File

@ -3,10 +3,9 @@ import classnames from 'classnames';
import React, { useState } from 'react'; import React, { useState } from 'react';
import Support from './Support'; import Support from './Support';
import General from './General'; import General from './General';
import Font from './Font';
import Theme from './Theme';
import Proxy from './ProxySettings'; import Proxy from './ProxySettings';
import StyledWrapper from './StyledWrapper'; import StyledWrapper from './StyledWrapper';
import Display from './Display/index';
const Preferences = ({ onClose }) => { const Preferences = ({ onClose }) => {
const [tab, setTab] = useState('general'); const [tab, setTab] = useState('general');
@ -27,32 +26,26 @@ const Preferences = ({ onClose }) => {
return <Proxy close={onClose} />; return <Proxy close={onClose} />;
} }
case 'theme': { case 'display': {
return <Theme close={onClose} />; return <Display close={onClose} />;
} }
case 'support': { case 'support': {
return <Support />; return <Support />;
} }
case 'font': {
return <Font close={onClose} />;
}
} }
}; };
return ( return (
<StyledWrapper> <StyledWrapper>
<Modal size="lg" title="Preferences" handleCancel={onClose} hideFooter={true}> <Modal size="lg" title="Preferences" handleCancel={onClose} hideFooter={true}>
<div className="flex items-center px-2 tabs" role="tablist"> <div className='flex flex-row gap-2 mx-[-1rem] !my-[-1.5rem]'>
<div className="flex flex-col items-center tabs" role="tablist">
<div className={getTabClassname('general')} role="tab" onClick={() => setTab('general')}> <div className={getTabClassname('general')} role="tab" onClick={() => setTab('general')}>
General General
</div> </div>
<div className={getTabClassname('theme')} role="tab" onClick={() => setTab('theme')}> <div className={getTabClassname('display')} role="tab" onClick={() => setTab('display')}>
Theme Display
</div>
<div className={getTabClassname('font')} role="tab" onClick={() => setTab('font')}>
Font
</div> </div>
<div className={getTabClassname('proxy')} role="tab" onClick={() => setTab('proxy')}> <div className={getTabClassname('proxy')} role="tab" onClick={() => setTab('proxy')}>
Proxy Proxy
@ -61,7 +54,8 @@ const Preferences = ({ onClose }) => {
Support Support
</div> </div>
</div> </div>
<section className="flex flex-grow px-2 mt-4 tab-panel">{getTabPanel(tab)}</section> <section className="flex flex-grow px-2 pt-2 pb-6 tab-panel">{getTabPanel(tab)}</section>
</div>
</Modal> </Modal>
</StyledWrapper> </StyledWrapper>
); );

View File

@ -30,7 +30,6 @@ const AuthMode = ({ item, collection }) => {
}) })
); );
}; };
return ( return (
<StyledWrapper> <StyledWrapper>
<div className="inline-flex items-center cursor-pointer auth-mode-selector"> <div className="inline-flex items-center cursor-pointer auth-mode-selector">
@ -80,6 +79,15 @@ const AuthMode = ({ item, collection }) => {
> >
OAuth 2.0 OAuth 2.0
</div> </div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef?.current?.hide();
onModeChange('wsse');
}}
>
WSSE Auth
</div>
<div <div
className="dropdown-item" className="dropdown-item"
onClick={() => { onClick={() => {

View File

@ -0,0 +1,17 @@
import styled from 'styled-components';
const Wrapper = styled.div`
label {
font-size: 0.8125rem;
}
.single-line-editor-wrapper {
max-width: 400px;
padding: 0.15rem 0.4rem;
border-radius: 3px;
border: solid 1px ${(props) => props.theme.input.border};
background-color: ${(props) => props.theme.input.bg};
}
`;
export default Wrapper;

View File

@ -0,0 +1,76 @@
import React from 'react';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import SingleLineEditor from 'components/SingleLineEditor';
import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
const WsseAuth = ({ item, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const wsseAuth = item.draft ? get(item, 'draft.request.auth.wsse', {}) : get(item, 'request.auth.wsse', {});
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleUserChange = (username) => {
dispatch(
updateAuth({
mode: 'wsse',
collectionUid: collection.uid,
itemUid: item.uid,
content: {
username,
password: wsseAuth.password
}
})
);
};
const handlePasswordChange = (password) => {
dispatch(
updateAuth({
mode: 'wsse',
collectionUid: collection.uid,
itemUid: item.uid,
content: {
username: wsseAuth.username,
password
}
})
);
};
return (
<StyledWrapper className="mt-2 w-full">
<label className="block font-medium mb-2">Username</label>
<div className="single-line-editor-wrapper mb-2">
<SingleLineEditor
value={wsseAuth.username || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleUserChange(val)}
onRun={handleRun}
collection={collection}
/>
</div>
<label className="block font-medium mb-2">Password</label>
<div className="single-line-editor-wrapper">
<SingleLineEditor
value={wsseAuth.password || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handlePasswordChange(val)}
onRun={handleRun}
collection={collection}
/>
</div>
</StyledWrapper>
);
};
export default WsseAuth;

View File

@ -5,6 +5,7 @@ import AwsV4Auth from './AwsV4Auth';
import BearerAuth from './BearerAuth'; import BearerAuth from './BearerAuth';
import BasicAuth from './BasicAuth'; import BasicAuth from './BasicAuth';
import DigestAuth from './DigestAuth'; import DigestAuth from './DigestAuth';
import WsseAuth from './WsseAuth';
import ApiKeyAuth from './ApiKeyAuth'; import ApiKeyAuth from './ApiKeyAuth';
import StyledWrapper from './StyledWrapper'; import StyledWrapper from './StyledWrapper';
import { humanizeRequestAuthMode } from 'utils/collections/index'; import { humanizeRequestAuthMode } from 'utils/collections/index';
@ -33,6 +34,9 @@ const Auth = ({ item, collection }) => {
case 'oauth2': { case 'oauth2': {
return <OAuth2 collection={collection} item={item} />; return <OAuth2 collection={collection} item={item} />;
} }
case 'wsse': {
return <WsseAuth collection={collection} item={item} />;
}
case 'apikey': { case 'apikey': {
return <ApiKeyAuth collection={collection} item={item} />; return <ApiKeyAuth collection={collection} item={item} />;
} }

View File

@ -83,7 +83,6 @@ const VarsTable = ({ item, collection, vars, varType }) => {
<td> <td>
<div className="flex items-center"> <div className="flex items-center">
<span>Value</span> <span>Value</span>
<InfoTip text="You can write any valid JS Template Literal here" infotipId="request-var" />
</div> </div>
</td> </td>
) : ( ) : (

View File

@ -18,6 +18,7 @@ import CloneCollectionItem from 'components/Sidebar/Collections/Collection/Colle
import NewRequest from 'components/Sidebar/NewRequest/index'; import NewRequest from 'components/Sidebar/NewRequest/index';
import CloseTabIcon from './CloseTabIcon'; import CloseTabIcon from './CloseTabIcon';
import DraftTabIcon from './DraftTabIcon'; import DraftTabIcon from './DraftTabIcon';
import { flattenItems } from 'utils/collections/index';
const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUid }) => { const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUid }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
@ -282,11 +283,10 @@ function RequestTabMenu({ onDropdownCreate, onCloseTabs, collectionRequestTabs,
function handleCloseSavedTabs(event) { function handleCloseSavedTabs(event) {
event.stopPropagation(); event.stopPropagation();
const savedTabs = collectionRequestTabs.filter((tab) => { const items = flattenItems(collection?.items);
const item = findItemInCollection(collection, tab.uid) const savedTabs = items?.filter?.((item) => !item.draft);
return item && !item.draft; const savedTabIds = savedTabs?.map((item) => item.uid) || [];
}); onCloseTabs(savedTabIds);
onCloseTabs(savedTabs.map((tab) => tab.uid));
} }
function handleCloseAllTabs(event) { function handleCloseAllTabs(event) {

View File

@ -15,7 +15,7 @@ import StyledWrapper from './StyledWrapper';
const ResponsePane = ({ rightPaneWidth, item, collection }) => { const ResponsePane = ({ rightPaneWidth, item, collection }) => {
const [selectedTab, setSelectedTab] = useState('response'); const [selectedTab, setSelectedTab] = useState('response');
const { requestSent, responseReceived, testResults, assertionResults } = item; const { requestSent, responseReceived, testResults, assertionResults, error } = item;
const headers = get(item, 'responseReceived.headers', []); const headers = get(item, 'responseReceived.headers', []);
const status = get(item, 'responseReceived.status', 0); const status = get(item, 'responseReceived.status', 0);
@ -36,6 +36,7 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
data={responseReceived.data} data={responseReceived.data}
dataBuffer={responseReceived.dataBuffer} dataBuffer={responseReceived.dataBuffer}
headers={responseReceived.headers} headers={responseReceived.headers}
error={error}
key={item.filename} key={item.filename}
/> />
); );

View File

@ -32,7 +32,10 @@ const CodeView = ({ language, item }) => {
let snippet = ''; let snippet = '';
try { try {
snippet = new HTTPSnippet(buildHarRequest({ request: item.request, headers })).convert(target, client); snippet = new HTTPSnippet(buildHarRequest({ request: item.request, headers, type: item.type })).convert(
target,
client
);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
snippet = 'Error generating code snippet'; snippet = 'Error generating code snippet';

View File

@ -28,9 +28,12 @@ const RenameCollectionItem = ({ collection, item, onClose }) => {
if (!isFolder && item.draft) { if (!isFolder && item.draft) {
await dispatch(saveRequest(item.uid, collection.uid, true)); await dispatch(saveRequest(item.uid, collection.uid, true));
} }
if (item.name === values.name) {
return;
}
dispatch(renameItem(values.name, item.uid, collection.uid)) dispatch(renameItem(values.name, item.uid, collection.uid))
.then(() => { .then(() => {
toast.success('Request renamed!'); toast.success('Request renamed');
onClose(); onClose();
}) })
.catch((err) => { .catch((err) => {
@ -55,7 +58,7 @@ const RenameCollectionItem = ({ collection, item, onClose }) => {
handleConfirm={onSubmit} handleConfirm={onSubmit}
handleCancel={onClose} handleCancel={onClose}
> >
<form className="bruno-form" onSubmit={e => e.preventDefault()}> <form className="bruno-form" onSubmit={(e) => e.preventDefault()}>
<div> <div>
<label htmlFor="name" className="block font-semibold"> <label htmlFor="name" className="block font-semibold">
{isFolder ? 'Folder' : 'Request'} Name {isFolder ? 'Folder' : 'Request'} Name

View File

@ -349,7 +349,7 @@ const CollectionItem = ({ item, collection, searchText }) => {
Run Run
</div> </div>
)} )}
{!isFolder && item.type === 'http-request' && ( {!isFolder && (item.type === 'http-request' || item.type === 'graphql-request') && (
<div <div
className="dropdown-item" className="dropdown-item"
onClick={(e) => { onClick={(e) => {

View File

@ -185,7 +185,7 @@ const Sidebar = () => {
Star Star
</GitHubButton> */} </GitHubButton> */}
</div> </div>
<div className="flex flex-grow items-center justify-end text-xs mr-2">v1.30.0</div> <div className="flex flex-grow items-center justify-end text-xs mr-2">v1.30.1</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,25 +1,23 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
const StopWatch = ({ requestTimestamp }) => { const StopWatch = () => {
const [milliseconds, setMilliseconds] = useState(0); const [milliseconds, setMilliseconds] = useState(0);
const tickInterval = 200; const tickInterval = 100;
const tick = () => { const tick = () => {
setMilliseconds(milliseconds + tickInterval); setMilliseconds(_milliseconds => _milliseconds + tickInterval);
}; };
useEffect(() => { useEffect(() => {
let timerID = setInterval(() => tick(), tickInterval); let timerID = setInterval(() => {
tick()
}, tickInterval);
return () => { return () => {
clearInterval(timerID); clearTimeout(timerID);
}; };
}); }, []);
useEffect(() => { if (milliseconds < 250) {
setMilliseconds(Date.now() - requestTimestamp);
}, [requestTimestamp]);
if (milliseconds < 1000) {
return 'Loading...'; return 'Loading...';
} }
@ -27,4 +25,4 @@ const StopWatch = ({ requestTimestamp }) => {
return <span>{seconds.toFixed(1)}s</span>; return <span>{seconds.toFixed(1)}s</span>;
}; };
export default StopWatch; export default React.memo(StopWatch);

View File

@ -21,9 +21,7 @@ const Welcome = () => {
const [importCollectionLocationModalOpen, setImportCollectionLocationModalOpen] = useState(false); const [importCollectionLocationModalOpen, setImportCollectionLocationModalOpen] = useState(false);
const handleOpenCollection = () => { const handleOpenCollection = () => {
dispatch(openCollection()).catch( dispatch(openCollection()).catch((err) => console.log(err) && toast.error(t('WELCOME.COLLECTION_OPEN_ERROR')));
(err) => console.log(err) && toast.error(t('WELCOME.COLLECTION_OPEN_ERROR'))
);
}; };
const handleImportCollection = ({ collection, translationLog }) => { const handleImportCollection = ({ collection, translationLog }) => {
@ -64,7 +62,7 @@ const Welcome = () => {
/> />
) : null} ) : null}
<div> <div aria-hidden className="">
<Bruno width={50} /> <Bruno width={50} />
</div> </div>
<div className="text-xl font-semibold select-none">bruno</div> <div className="text-xl font-semibold select-none">bruno</div>
@ -72,40 +70,69 @@ const Welcome = () => {
<div className="uppercase font-semibold heading mt-10">{t('COMMON.COLLECTIONS')}</div> <div className="uppercase font-semibold heading mt-10">{t('COMMON.COLLECTIONS')}</div>
<div className="mt-4 flex items-center collection-options select-none"> <div className="mt-4 flex items-center collection-options select-none">
<div className="flex items-center" onClick={() => setCreateCollectionModalOpen(true)}> <button
<IconPlus size={18} strokeWidth={2} /> className="flex items-center"
onClick={() => setCreateCollectionModalOpen(true)}
aria-label={t('WELCOME.CREATE_COLLECTION')}
>
<IconPlus aria-hidden size={18} strokeWidth={2} />
<span className="label ml-2" id="create-collection"> <span className="label ml-2" id="create-collection">
{t('WELCOME.CREATE_COLLECTION')} {t('WELCOME.CREATE_COLLECTION')}
</span> </span>
</div> </button>
<div className="flex items-center ml-6" onClick={handleOpenCollection}>
<IconFolders size={18} strokeWidth={2} /> <button className="flex items-center ml-6" onClick={handleOpenCollection} aria-label="Open Collection">
<IconFolders aria-hidden size={18} strokeWidth={2} />
<span className="label ml-2">{t('WELCOME.OPEN_COLLECTION')}</span> <span className="label ml-2">{t('WELCOME.OPEN_COLLECTION')}</span>
</div> </button>
<div className="flex items-center ml-6" onClick={() => setImportCollectionModalOpen(true)}>
<IconDownload size={18} strokeWidth={2} /> <button
className="flex items-center ml-6"
onClick={() => setImportCollectionModalOpen(true)}
aria-label={t('WELCOME.IMPORT_COLLECTION')}
>
<IconDownload aria-hidden size={18} strokeWidth={2} />
<span className="label ml-2" id="import-collection"> <span className="label ml-2" id="import-collection">
{t('WELCOME.IMPORT_COLLECTION')} {t('WELCOME.IMPORT_COLLECTION')}
</span> </span>
</button>
</div> </div>
</div>
<div className="uppercase font-semibold heading mt-10 pt-6">{t('WELCOME.LINKS')}</div> <div className="uppercase font-semibold heading mt-10 pt-6">{t('WELCOME.LINKS')}</div>
<div className="mt-4 flex flex-col collection-options select-none"> <div className="mt-4 flex flex-col collection-options select-none">
<div className="flex items-center mt-2"> <div className="flex items-center mt-2">
<a href="https://docs.usebruno.com" target="_blank" className="inline-flex items-center"> <a
<IconBook size={18} strokeWidth={2} /> href="https://docs.usebruno.com"
aria-label="Read documentation"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center"
>
<IconBook aria-hidden size={18} strokeWidth={2} />
<span className="label ml-2">{t('COMMON.DOCUMENTATION')}</span> <span className="label ml-2">{t('COMMON.DOCUMENTATION')}</span>
</a> </a>
</div> </div>
<div className="flex items-center mt-2"> <div className="flex items-center mt-2">
<a href="https://github.com/usebruno/bruno/issues" target="_blank" className="inline-flex items-center"> <a
<IconSpeakerphone size={18} strokeWidth={2} /> href="https://github.com/usebruno/bruno/issues"
aria-label="Report issues on GitHub"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center"
>
<IconSpeakerphone aria-hidden size={18} strokeWidth={2} />
<span className="label ml-2">{t('COMMON.REPORT_ISSUES')}</span> <span className="label ml-2">{t('COMMON.REPORT_ISSUES')}</span>
</a> </a>
</div> </div>
<div className="flex items-center mt-2"> <div className="flex items-center mt-2">
<a href="https://github.com/usebruno/bruno" target="_blank" className="flex items-center"> <a
<IconBrandGithub size={18} strokeWidth={2} /> href="https://github.com/usebruno/bruno"
aria-label="Go to GitHub repository"
target="_blank"
rel="noopener noreferrer"
className="flex items-center"
>
<IconBrandGithub aria-hidden size={18} strokeWidth={2} />
<span className="label ml-2">{t('COMMON.GITHUB')}</span> <span className="label ml-2">{t('COMMON.GITHUB')}</span>
</a> </a>
</div> </div>

View File

@ -60,7 +60,7 @@ const trackStart = () => {
event: 'start', event: 'start',
properties: { properties: {
os: platformLib.os.family, os: platformLib.os.family,
version: '1.30.0' version: '1.30.1'
} }
}); });
}; };

View File

@ -43,6 +43,7 @@ import { resolveRequestFilename } from 'utils/common/platform';
import { parsePathParams, parseQueryParams, splitOnFirst } from 'utils/url/index'; import { parsePathParams, parseQueryParams, splitOnFirst } from 'utils/url/index';
import { sendCollectionOauth2Request as _sendCollectionOauth2Request } from 'utils/network/index'; import { sendCollectionOauth2Request as _sendCollectionOauth2Request } from 'utils/network/index';
import { name } from 'file-loader'; import { name } from 'file-loader';
import slash from 'utils/common/slash';
export const renameCollection = (newName, collectionUid) => (dispatch, getState) => { export const renameCollection = (newName, collectionUid) => (dispatch, getState) => {
const state = getState(); const state = getState();
@ -401,7 +402,7 @@ export const renameItem = (newName, itemUid, collectionUid) => (dispatch, getSta
} }
const { ipcRenderer } = window; const { ipcRenderer } = window;
ipcRenderer.invoke('renderer:rename-item', item.pathname, newPathname, newName).then(resolve).catch(reject); ipcRenderer.invoke('renderer:rename-item', slash(item.pathname), newPathname, newName).then(resolve).catch(reject);
}); });
}; };

View File

@ -477,6 +477,10 @@ export const collectionsSlice = createSlice({
item.draft.request.auth.mode = 'oauth2'; item.draft.request.auth.mode = 'oauth2';
item.draft.request.auth.oauth2 = action.payload.content; item.draft.request.auth.oauth2 = action.payload.content;
break; break;
case 'wsse':
item.draft.request.auth.mode = 'wsse';
item.draft.request.auth.wsse = action.payload.content;
break;
case 'apikey': case 'apikey':
item.draft.request.auth.mode = 'apikey'; item.draft.request.auth.mode = 'apikey';
item.draft.request.auth.apikey = action.payload.content; item.draft.request.auth.apikey = action.payload.content;
@ -1141,6 +1145,9 @@ export const collectionsSlice = createSlice({
case 'oauth2': case 'oauth2':
set(collection, 'root.request.auth.oauth2', action.payload.content); set(collection, 'root.request.auth.oauth2', action.payload.content);
break; break;
case 'wsse':
set(collection, 'root.request.auth.wsse', action.payload.content);
break;
case 'apikey': case 'apikey':
set(collection, 'root.request.auth.apikey', action.payload.content); set(collection, 'root.request.auth.apikey', action.payload.content);
break; break;

View File

@ -31,6 +31,7 @@ const createHeaders = (request, headers) => {
if (contentType !== '') { if (contentType !== '') {
enabledHeaders.push({ name: 'content-type', value: contentType }); enabledHeaders.push({ name: 'content-type', value: contentType });
} }
return enabledHeaders; return enabledHeaders;
}; };
@ -43,7 +44,14 @@ const createQuery = (queryParams = []) => {
})); }));
}; };
const createPostData = (body) => { const createPostData = (body, type) => {
if (type === 'graphql-request') {
return {
mimeType: 'application/json',
text: JSON.stringify(body[body.mode])
};
}
const contentType = createContentType(body.mode); const contentType = createContentType(body.mode);
if (body.mode === 'formUrlEncoded' || body.mode === 'multipartForm') { if (body.mode === 'formUrlEncoded' || body.mode === 'multipartForm') {
return { return {
@ -64,7 +72,7 @@ const createPostData = (body) => {
} }
}; };
export const buildHarRequest = ({ request, headers }) => { export const buildHarRequest = ({ request, headers, type }) => {
return { return {
method: request.method, method: request.method,
url: encodeURI(request.url), url: encodeURI(request.url),
@ -72,7 +80,7 @@ export const buildHarRequest = ({ request, headers }) => {
cookies: [], cookies: [],
headers: createHeaders(request, headers), headers: createHeaders(request, headers),
queryString: createQuery(request.params), queryString: createQuery(request.params),
postData: createPostData(request.body), postData: createPostData(request.body, type),
headersSize: 0, headersSize: 0,
bodySize: 0 bodySize: 0
}; };

View File

@ -379,7 +379,12 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
placement: get(si.request, 'auth.apikey.placement', 'header') placement: get(si.request, 'auth.apikey.placement', 'header')
}; };
break; break;
case 'wsse':
di.request.auth.wsse = {
username: get(si.request, 'auth.wsse.username', ''),
password: get(si.request, 'auth.wsse.password', '')
};
break;
default: default:
break; break;
} }
@ -669,6 +674,10 @@ export const humanizeRequestAuthMode = (mode) => {
label = 'OAuth 2.0'; label = 'OAuth 2.0';
break; break;
} }
case 'wsse': {
label = 'WSSE Auth';
break;
}
case 'apikey': { case 'apikey': {
label = 'API Key'; label = 'API Key';
break; break;

View File

@ -174,7 +174,7 @@ const transformInsomniaRequestItem = (request, index, allRequests) => {
} else if (mimeType === 'text/plain') { } else if (mimeType === 'text/plain') {
brunoRequestItem.request.body.mode = 'text'; brunoRequestItem.request.body.mode = 'text';
brunoRequestItem.request.body.text = request.body.text; brunoRequestItem.request.body.text = request.body.text;
} else if (mimeType === 'text/xml') { } else if (mimeType === 'text/xml' || mimeType === 'application/xml') {
brunoRequestItem.request.body.mode = 'xml'; brunoRequestItem.request.body.mode = 'xml';
brunoRequestItem.request.body.xml = request.body.text; brunoRequestItem.request.body.xml = request.body.text;
} else if (mimeType === 'application/graphql') { } else if (mimeType === 'application/graphql') {

View File

@ -32,7 +32,7 @@ const readFile = (files) => {
const ensureUrl = (url) => { const ensureUrl = (url) => {
// emoving multiple slashes after the protocol if it exists, or after the beginning of the string otherwise // emoving multiple slashes after the protocol if it exists, or after the beginning of the string otherwise
return url.replace(/(^\w+:|^)\/{2,}/, '$1/'); return url.replace(/([^:])\/{2,}/g, '$1/');
}; };
const buildEmptyJsonBody = (bodySchema) => { const buildEmptyJsonBody = (bodySchema) => {

View File

@ -54,6 +54,47 @@ const convertV21Auth = (array) => {
}, {}); }, {});
}; };
const constructUrlFromParts = (url) => {
const { protocol = 'http', host, path, port, query, hash } = url || {};
const hostStr = Array.isArray(host) ? host.filter(Boolean).join('.') : host || '';
const pathStr = Array.isArray(path) ? path.filter(Boolean).join('/') : path || '';
const portStr = port ? `:${port}` : '';
const queryStr =
query && Array.isArray(query) && query.length > 0
? `?${query
.filter((q) => q.key)
.map((q) => `${q.key}=${q.value || ''}`)
.join('&')}`
: '';
const urlStr = `${protocol}://${hostStr}${portStr}${pathStr ? `/${pathStr}` : ''}${queryStr}`;
return urlStr;
};
const constructUrl = (url) => {
if (!url) return '';
if (typeof url === 'string') {
return url;
}
if (typeof url === 'object') {
const { raw } = url;
if (raw && typeof raw === 'string') {
// If the raw URL contains url-fragments remove it
if (raw.includes('#')) {
return raw.split('#')[0]; // Returns the part of raw URL without the url-fragment part.
}
return raw;
}
// If no raw value exists, construct the URL from parts
return constructUrlFromParts(url);
}
return '';
};
let translationLog = {}; let translationLog = {};
const importPostmanV2CollectionItem = (brunoParent, item, parentAuth, options) => { const importPostmanV2CollectionItem = (brunoParent, item, parentAuth, options) => {
@ -94,12 +135,7 @@ const importPostmanV2CollectionItem = (brunoParent, item, parentAuth, options) =
count++; count++;
} }
let url = ''; const url = constructUrl(i.request.url);
if (typeof i.request.url === 'string') {
url = i.request.url;
} else {
url = get(i, 'request.url.raw') || '';
}
const brunoRequestItem = { const brunoRequestItem = {
uid: uuid(), uid: uuid(),
@ -107,7 +143,7 @@ const importPostmanV2CollectionItem = (brunoParent, item, parentAuth, options) =
type: 'http-request', type: 'http-request',
request: { request: {
url: url, url: url,
method: i.request.method, method: i?.request?.method?.toUpperCase(),
auth: { auth: {
mode: 'none', mode: 'none',
basic: null, basic: null,
@ -313,12 +349,17 @@ const importPostmanV2CollectionItem = (brunoParent, item, parentAuth, options) =
}); });
}); });
each(get(i, 'request.url.variable'), (param) => { each(get(i, 'request.url.variable', []), (param) => {
if (!param.key) {
// If no key, skip this iteration and discard the param
return;
}
brunoRequestItem.request.params.push({ brunoRequestItem.request.params.push({
uid: uuid(), uid: uuid(),
name: param.key, name: param.key,
value: param.value, value: param.value ?? '',
description: param.description, description: param.description ?? '',
type: 'path', type: 'path',
enabled: true enabled: true
}); });

View File

@ -1,5 +1,6 @@
const { interpolate } = require('@usebruno/common'); const { interpolate } = require('@usebruno/common');
const { each, forOwn, cloneDeep, find } = require('lodash'); const { each, forOwn, cloneDeep, find } = require('lodash');
const FormData = require('form-data');
const getContentType = (headers = {}) => { const getContentType = (headers = {}) => {
let contentType = ''; let contentType = '';
@ -78,6 +79,14 @@ const interpolateVars = (request, envVars = {}, runtimeVariables = {}, processEn
request.data = JSON.parse(parsed); request.data = JSON.parse(parsed);
} catch (err) {} } catch (err) {}
} }
} else if (contentType === 'multipart/form-data') {
if (typeof request.data === 'object' && !(request?.data instanceof FormData)) {
try {
let parsed = JSON.stringify(request.data);
parsed = _interpolate(parsed);
request.data = JSON.parse(parsed);
} catch (err) {}
}
} else { } else {
request.data = _interpolate(request.data); request.data = _interpolate(request.data);
} }
@ -113,7 +122,8 @@ const interpolateVars = (request, envVars = {}, runtimeVariables = {}, processEn
}) })
.join(''); .join('');
request.url = url.origin + interpolatedUrlPath + url.search; const trailingSlash = url.pathname.endsWith('/') ? '/' : '';
request.url = url.origin + interpolatedUrlPath + trailingSlash + url.search;
} }
if (request.proxy) { if (request.proxy) {

View File

@ -2,6 +2,7 @@ const { get, each, filter } = require('lodash');
const fs = require('fs'); const fs = require('fs');
var JSONbig = require('json-bigint'); var JSONbig = require('json-bigint');
const decomment = require('decomment'); const decomment = require('decomment');
const crypto = require('node:crypto');
const prepareRequest = (request, collectionRoot) => { const prepareRequest = (request, collectionRoot) => {
const headers = {}; const headers = {};
@ -69,6 +70,24 @@ const prepareRequest = (request, collectionRoot) => {
if (request.auth.mode === 'bearer') { if (request.auth.mode === 'bearer') {
axiosRequest.headers['Authorization'] = `Bearer ${get(request, 'auth.bearer.token')}`; axiosRequest.headers['Authorization'] = `Bearer ${get(request, 'auth.bearer.token')}`;
} }
if (request.auth.mode === 'wsse') {
const username = get(request, 'auth.wsse.username', '');
const password = get(request, 'auth.wsse.password', '');
const ts = new Date().toISOString();
const nonce = crypto.randomBytes(16).toString('base64');
// Create the password digest using SHA-256
const hash = crypto.createHash('sha256');
hash.update(nonce + ts + password);
const digest = hash.digest('base64');
// Construct the WSSE header
axiosRequest.headers[
'X-WSSE'
] = `UsernameToken Username="${username}", PasswordDigest="${digest}", Created="${ts}", Nonce="${nonce}"`;
}
} }
request.body = request.body || {}; request.body = request.body || {};
@ -120,16 +139,10 @@ const prepareRequest = (request, collectionRoot) => {
} }
if (request.body.mode === 'multipartForm') { if (request.body.mode === 'multipartForm') {
axiosRequest.headers['content-type'] = 'multipart/form-data';
const params = {}; const params = {};
const enabledParams = filter(request.body.multipartForm, (p) => p.enabled); const enabledParams = filter(request.body.multipartForm, (p) => p.enabled);
each(enabledParams, (p) => { each(enabledParams, (p) => (params[p.name] = p.value));
if (p.type === 'file') {
params[p.name] = p.value.map((path) => fs.createReadStream(path));
} else {
params[p.name] = p.value;
}
});
axiosRequest.headers['content-type'] = 'multipart/form-data';
axiosRequest.data = params; axiosRequest.data = params;
} }

View File

@ -19,6 +19,7 @@ const { makeAxiosInstance } = require('../utils/axios-instance');
const { addAwsV4Interceptor, resolveAwsV4Credentials } = require('./awsv4auth-helper'); const { addAwsV4Interceptor, resolveAwsV4Credentials } = require('./awsv4auth-helper');
const { shouldUseProxy, PatchedHttpsProxyAgent } = require('../utils/proxy-util'); const { shouldUseProxy, PatchedHttpsProxyAgent } = require('../utils/proxy-util');
const path = require('path'); const path = require('path');
const { createFormData } = require('../utils/common');
const protocolRegex = /^([-+\w]{1,25})(:?\/\/|:)/; const protocolRegex = /^([-+\w]{1,25})(:?\/\/|:)/;
const onConsoleLog = (type, args) => { const onConsoleLog = (type, args) => {
@ -42,24 +43,11 @@ const runSingleRequest = async function (
request = prepareRequest(bruJson.request, collectionRoot); request = prepareRequest(bruJson.request, collectionRoot);
request.__bruno__executionMode = 'cli';
const scriptingConfig = get(brunoConfig, 'scripts', {}); const scriptingConfig = get(brunoConfig, 'scripts', {});
scriptingConfig.runtime = runtime; scriptingConfig.runtime = runtime;
// make axios work in node using form data
// reference: https://github.com/axios/axios/issues/1006#issuecomment-320165427
if (request.headers && request.headers['content-type'] === 'multipart/form-data') {
const form = new FormData();
forOwn(request.data, (value, key) => {
if (value instanceof Array) {
each(value, (v) => form.append(key, v));
} else {
form.append(key, value);
}
});
extend(request.headers, form.getHeaders());
request.data = form;
}
// run pre request script // run pre request script
const requestScriptFile = compact([ const requestScriptFile = compact([
get(collectionRoot, 'request.script.req'), get(collectionRoot, 'request.script.req'),
@ -195,6 +183,14 @@ const runSingleRequest = async function (
request.data = qs.stringify(request.data); request.data = qs.stringify(request.data);
} }
if (request?.headers?.['content-type'] === 'multipart/form-data') {
if (!(request?.data instanceof FormData)) {
let form = createFormData(request.data, collectionPath);
request.data = form;
extend(request.headers, form.getHeaders());
}
}
let response, responseTime; let response, responseTime;
try { try {
// run request // run request

View File

@ -1,3 +1,8 @@
const fs = require('fs');
const FormData = require('form-data');
const { forOwn } = require('lodash');
const path = require('path');
const lpad = (str, width) => { const lpad = (str, width) => {
let paddedStr = str; let paddedStr = str;
while (paddedStr.length < width) { while (paddedStr.length < width) {
@ -14,7 +19,33 @@ const rpad = (str, width) => {
return paddedStr; return paddedStr;
}; };
const createFormData = (datas, collectionPath) => {
// make axios work in node using form data
// reference: https://github.com/axios/axios/issues/1006#issuecomment-320165427
const form = new FormData();
forOwn(datas, (value, key) => {
if (typeof value == 'string') {
form.append(key, value);
return;
}
const filePaths = value || [];
filePaths?.forEach?.((filePath) => {
let trimmedFilePath = filePath.trim();
if (!path.isAbsolute(trimmedFilePath)) {
trimmedFilePath = path.join(collectionPath, trimmedFilePath);
}
form.append(key, fs.createReadStream(trimmedFilePath), path.basename(trimmedFilePath));
});
});
return form;
};
module.exports = { module.exports = {
lpad, lpad,
rpad rpad,
createFormData
}; };

View File

@ -1,5 +1,5 @@
{ {
"version": "v1.30.0", "version": "v1.30.1",
"name": "bruno", "name": "bruno",
"description": "Opensource API Client for Exploring and Testing APIs", "description": "Opensource API Client for Exploring and Testing APIs",
"homepage": "https://www.usebruno.com", "homepage": "https://www.usebruno.com",

View File

@ -16,6 +16,8 @@ const {
sanitizeDirectoryName, sanitizeDirectoryName,
isWSLPath, isWSLPath,
normalizeWslPath, normalizeWslPath,
normalizeAndResolvePath,
safeToRename
} = require('../utils/filesystem'); } = require('../utils/filesystem');
const { openCollectionDialog } = require('../app/collections'); const { openCollectionDialog } = require('../app/collections');
const { generateUidBasedOnHash, stringifyJson, safeParseJSON, safeStringifyJSON } = require('../utils/common'); const { generateUidBasedOnHash, stringifyJson, safeParseJSON, safeStringifyJSON } = require('../utils/common');
@ -296,7 +298,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
} }
const newEnvFilePath = path.join(envDirPath, `${newName}.bru`); const newEnvFilePath = path.join(envDirPath, `${newName}.bru`);
if (fs.existsSync(newEnvFilePath)) { if (!safeToRename(envFilePath, newEnvFilePath)) {
throw new Error(`environment: ${newEnvFilePath} already exists`); throw new Error(`environment: ${newEnvFilePath} already exists`);
} }
@ -329,21 +331,18 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
ipcMain.handle('renderer:rename-item', async (event, oldPath, newPath, newName) => { ipcMain.handle('renderer:rename-item', async (event, oldPath, newPath, newName) => {
try { try {
// Normalize paths if they are WSL paths // Normalize paths if they are WSL paths
if (isWSLPath(oldPath)) { oldPath = isWSLPath(oldPath) ? normalizeWslPath(oldPath) : normalizeAndResolvePath(oldPath);
oldPath = normalizeWslPath(oldPath); newPath = isWSLPath(newPath) ? normalizeWslPath(newPath) : normalizeAndResolvePath(newPath);
}
if (isWSLPath(newPath)) {
newPath = normalizeWslPath(newPath);
}
// Check if the old path exists
if (!fs.existsSync(oldPath)) { if (!fs.existsSync(oldPath)) {
throw new Error(`path: ${oldPath} does not exist`); throw new Error(`path: ${oldPath} does not exist`);
} }
if (fs.existsSync(newPath)) {
throw new Error(`path: ${oldPath} already exists`); if (!safeToRename(oldPath, newPath)) {
throw new Error(`path: ${newPath} already exists`);
} }
// if its directory, rename and return
if (isDirectory(oldPath)) { if (isDirectory(oldPath)) {
const bruFilesAtSource = await searchForBruFiles(oldPath); const bruFilesAtSource = await searchForBruFiles(oldPath);
@ -364,12 +363,13 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
const jsonData = bruToJson(data); const jsonData = bruToJson(data);
jsonData.name = newName; jsonData.name = newName;
moveRequestUid(oldPath, newPath); moveRequestUid(oldPath, newPath);
const content = jsonToBru(jsonData); const content = jsonToBru(jsonData);
await writeFile(newPath, content);
await fs.unlinkSync(oldPath); await fs.unlinkSync(oldPath);
await writeFile(newPath, content);
return newPath;
} catch (error) { } catch (error) {
return Promise.reject(error); return Promise.reject(error);
} }

View File

@ -9,7 +9,7 @@ const decomment = require('decomment');
const contentDispositionParser = require('content-disposition'); const contentDispositionParser = require('content-disposition');
const mime = require('mime-types'); const mime = require('mime-types');
const { ipcMain } = require('electron'); const { ipcMain } = require('electron');
const { isUndefined, isNull, each, get, compact, cloneDeep } = require('lodash'); const { isUndefined, isNull, each, get, compact, cloneDeep, forOwn, extend } = require('lodash');
const { VarsRuntime, AssertRuntime, ScriptRuntime, TestRuntime } = require('@usebruno/js'); const { VarsRuntime, AssertRuntime, ScriptRuntime, TestRuntime } = require('@usebruno/js');
const prepareRequest = require('./prepare-request'); const prepareRequest = require('./prepare-request');
const prepareCollectionRequest = require('./prepare-collection-request'); const prepareCollectionRequest = require('./prepare-collection-request');
@ -37,6 +37,8 @@ const {
} = require('./oauth2-helper'); } = require('./oauth2-helper');
const Oauth2Store = require('../../store/oauth2'); const Oauth2Store = require('../../store/oauth2');
const iconv = require('iconv-lite'); const iconv = require('iconv-lite');
const FormData = require('form-data');
const { createFormData } = prepareRequest;
const safeStringifyJSON = (data) => { const safeStringifyJSON = (data) => {
try { try {
@ -423,6 +425,14 @@ const registerNetworkIpc = (mainWindow) => {
request.data = qs.stringify(request.data); request.data = qs.stringify(request.data);
} }
if (request.headers['content-type'] === 'multipart/form-data') {
if (!(request.data instanceof FormData)) {
let form = createFormData(request.data, collectionPath);
request.data = form;
extend(request.headers, form.getHeaders());
}
}
return scriptResult; return scriptResult;
}; };
@ -515,6 +525,7 @@ const registerNetworkIpc = (mainWindow) => {
const collectionRoot = get(collection, 'root', {}); const collectionRoot = get(collection, 'root', {});
const request = prepareRequest(item, collection); const request = prepareRequest(item, collection);
request.__bruno__executionMode = 'standalone';
const envVars = getEnvVars(environment); const envVars = getEnvVars(environment);
const processEnvVars = getProcessEnvVars(collectionUid); const processEnvVars = getProcessEnvVars(collectionUid);
const brunoConfig = getBrunoConfig(collectionUid); const brunoConfig = getBrunoConfig(collectionUid);
@ -707,6 +718,7 @@ const registerNetworkIpc = (mainWindow) => {
const collectionRoot = get(collection, 'root', {}); const collectionRoot = get(collection, 'root', {});
const _request = collectionRoot?.request; const _request = collectionRoot?.request;
const request = prepareCollectionRequest(_request, collectionRoot, collectionPath); const request = prepareCollectionRequest(_request, collectionRoot, collectionPath);
request.__bruno__executionMode = 'standalone';
const envVars = getEnvVars(environment); const envVars = getEnvVars(environment);
const processEnvVars = getProcessEnvVars(collectionUid); const processEnvVars = getProcessEnvVars(collectionUid);
const brunoConfig = getBrunoConfig(collectionUid); const brunoConfig = getBrunoConfig(collectionUid);
@ -950,6 +962,8 @@ const registerNetworkIpc = (mainWindow) => {
}); });
const request = prepareRequest(item, collection); const request = prepareRequest(item, collection);
request.__bruno__executionMode = 'runner';
const requestUid = uuid(); const requestUid = uuid();
const processEnvVars = getProcessEnvVars(collectionUid); const processEnvVars = getProcessEnvVars(collectionUid);

View File

@ -1,5 +1,6 @@
const { interpolate } = require('@usebruno/common'); const { interpolate } = require('@usebruno/common');
const { each, forOwn, cloneDeep, find } = require('lodash'); const { each, forOwn, cloneDeep, find } = require('lodash');
const FormData = require('form-data');
const getContentType = (headers = {}) => { const getContentType = (headers = {}) => {
let contentType = ''; let contentType = '';
@ -76,6 +77,14 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
request.data = JSON.parse(parsed); request.data = JSON.parse(parsed);
} catch (err) {} } catch (err) {}
} }
} else if (contentType === 'multipart/form-data') {
if (typeof request.data === 'object' && !(request.data instanceof FormData)) {
try {
let parsed = JSON.stringify(request.data);
parsed = _interpolate(parsed);
request.data = JSON.parse(parsed);
} catch (err) {}
}
} else { } else {
request.data = _interpolate(request.data); request.data = _interpolate(request.data);
} }
@ -111,7 +120,8 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
}) })
.join(''); .join('');
request.url = url.origin + urlPathnameInterpolatedWithPathParams + url.search; const trailingSlash = url.pathname.endsWith('/') ? '/' : '';
request.url = url.origin + urlPathnameInterpolatedWithPathParams + trailingSlash + url.search;
} }
if (request.proxy) { if (request.proxy) {
@ -206,6 +216,12 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
request.digestConfig.password = _interpolate(request.digestConfig.password) || ''; request.digestConfig.password = _interpolate(request.digestConfig.password) || '';
} }
// interpolate vars for wsse auth
if (request.wsse) {
request.wsse.username = _interpolate(request.wsse.username) || '';
request.wsse.password = _interpolate(request.wsse.password) || '';
}
return request; return request;
}; };

View File

@ -1,9 +1,10 @@
const os = require('os'); const os = require('os');
const { get, each, filter, extend, compact } = require('lodash'); const { get, each, filter, compact, forOwn } = require('lodash');
const decomment = require('decomment'); const decomment = require('decomment');
const FormData = require('form-data'); const FormData = require('form-data');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const crypto = require('node:crypto');
const { getTreePathFromCollectionToItem } = require('../../utils/collection'); const { getTreePathFromCollectionToItem } = require('../../utils/collection');
const { buildFormUrlEncodedPayload } = require('../../utils/common'); const { buildFormUrlEncodedPayload } = require('../../utils/common');
@ -165,27 +166,26 @@ const mergeFolderLevelScripts = (request, requestTreePath, scriptFlow) => {
} }
}; };
const parseFormData = (datas, collectionPath) => { const createFormData = (datas, collectionPath) => {
// make axios work in node using form data // make axios work in node using form data
// reference: https://github.com/axios/axios/issues/1006#issuecomment-320165427 // reference: https://github.com/axios/axios/issues/1006#issuecomment-320165427
const form = new FormData(); const form = new FormData();
datas.forEach((item) => { forOwn(datas, (value, key) => {
const value = item.value; if (typeof value == 'string') {
const name = item.name; form.append(key, value);
if (item.type === 'file') { return;
}
const filePaths = value || []; const filePaths = value || [];
filePaths.forEach((filePath) => { filePaths?.forEach?.((filePath) => {
let trimmedFilePath = filePath.trim(); let trimmedFilePath = filePath.trim();
if (!path.isAbsolute(trimmedFilePath)) { if (!path.isAbsolute(trimmedFilePath)) {
trimmedFilePath = path.join(collectionPath, trimmedFilePath); trimmedFilePath = path.join(collectionPath, trimmedFilePath);
} }
form.append(name, fs.createReadStream(trimmedFilePath), path.basename(trimmedFilePath)); form.append(key, fs.createReadStream(trimmedFilePath), path.basename(trimmedFilePath));
}); });
} else {
form.append(name, value);
}
}); });
return form; return form;
}; };
@ -219,6 +219,23 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
password: get(collectionAuth, 'digest.password') password: get(collectionAuth, 'digest.password')
}; };
break; break;
case 'wsse':
const username = get(request, 'auth.wsse.username', '');
const password = get(request, 'auth.wsse.password', '');
const ts = new Date().toISOString();
const nonce = crypto.randomBytes(16).toString('base64');
// Create the password digest using SHA-256
const hash = crypto.createHash('sha256');
hash.update(nonce + ts + password);
const digest = hash.digest('base64');
// Construct the WSSE header
axiosRequest.headers[
'X-WSSE'
] = `UsernameToken Username="${username}", PasswordDigest="${digest}", Created="${ts}", Nonce="${nonce}"`;
break;
case 'apikey': case 'apikey':
const apiKeyAuth = get(collectionAuth, 'apikey'); const apiKeyAuth = get(collectionAuth, 'apikey');
if (apiKeyAuth.placement === 'header') { if (apiKeyAuth.placement === 'header') {
@ -296,6 +313,23 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
break; break;
} }
break; break;
case 'wsse':
const username = get(request, 'auth.wsse.username', '');
const password = get(request, 'auth.wsse.password', '');
const ts = new Date().toISOString();
const nonce = crypto.randomBytes(16).toString('base64');
// Create the password digest using SHA-256
const hash = crypto.createHash('sha256');
hash.update(nonce + ts + password);
const digest = hash.digest('base64');
// Construct the WSSE header
axiosRequest.headers[
'X-WSSE'
] = `UsernameToken Username="${username}", PasswordDigest="${digest}", Created="${ts}", Nonce="${nonce}"`;
break;
case 'apikey': case 'apikey':
const apiKeyAuth = get(request, 'auth.apikey'); const apiKeyAuth = get(request, 'auth.apikey');
if (apiKeyAuth.placement === 'header') { if (apiKeyAuth.placement === 'header') {
@ -400,10 +434,11 @@ const prepareRequest = (item, collection) => {
} }
if (request.body.mode === 'multipartForm') { if (request.body.mode === 'multipartForm') {
axiosRequest.headers['content-type'] = 'multipart/form-data';
const params = {};
const enabledParams = filter(request.body.multipartForm, (p) => p.enabled); const enabledParams = filter(request.body.multipartForm, (p) => p.enabled);
const form = parseFormData(enabledParams, collectionPath); each(enabledParams, (p) => (params[p.name] = p.value));
extend(axiosRequest.headers, form.getHeaders()); axiosRequest.data = params;
axiosRequest.data = form;
} }
if (request.body.mode === 'graphql') { if (request.body.mode === 'graphql') {
@ -433,3 +468,4 @@ const prepareRequest = (item, collection) => {
module.exports = prepareRequest; module.exports = prepareRequest;
module.exports.setAuthHeaders = setAuthHeaders; module.exports.setAuthHeaders = setAuthHeaders;
module.exports.createFormData = createFormData;

View File

@ -3,6 +3,7 @@ const fs = require('fs-extra');
const fsPromises = require('fs/promises'); const fsPromises = require('fs/promises');
const { dialog } = require('electron'); const { dialog } = require('electron');
const isValidPathname = require('is-valid-path'); const isValidPathname = require('is-valid-path');
const os = require('os');
const exists = async (p) => { const exists = async (p) => {
try { try {
@ -155,12 +156,34 @@ const searchForBruFiles = (dir) => {
return searchForFiles(dir, '.bru'); return searchForFiles(dir, '.bru');
}; };
// const isW
const sanitizeDirectoryName = (name) => { const sanitizeDirectoryName = (name) => {
return name.replace(/[<>:"/\\|?*\x00-\x1F]+/g, '-'); return name.replace(/[<>:"/\\|?*\x00-\x1F]+/g, '-');
}; };
const safeToRename = (oldPath, newPath) => {
try {
// If the new path doesn't exist, it's safe to rename
if (!fs.existsSync(newPath)) {
return true;
}
const oldStat = fs.statSync(oldPath);
const newStat = fs.statSync(newPath);
if (os.platform() === 'win32') {
// Windows-specific comparison:
// Check if both files have the same birth time, size (Since, Win FAT-32 doesn't use inodes)
return oldStat.birthtimeMs === newStat.birthtimeMs && oldStat.size === newStat.size;
}
// Unix/Linux/MacOS: Check inode to see if they are the same file
return oldStat.ino === newStat.ino;
} catch (error) {
console.error(`Error checking file rename safety for ${oldPath} and ${newPath}:`, error);
return false;
}
};
module.exports = { module.exports = {
isValidPathname, isValidPathname,
exists, exists,
@ -180,5 +203,6 @@ module.exports = {
chooseFileToSave, chooseFileToSave,
searchForFiles, searchForFiles,
searchForBruFiles, searchForBruFiles,
sanitizeDirectoryName sanitizeDirectoryName,
safeToRename
}; };

View File

@ -43,7 +43,6 @@ class BrunoRequest {
getMethod() { getMethod() {
return this.req.method; return this.req.method;
} }
getAuthMode() { getAuthMode() {
if (this.req?.oauth2) { if (this.req?.oauth2) {
return 'oauth2'; return 'oauth2';
@ -55,6 +54,8 @@ class BrunoRequest {
return 'awsv4'; return 'awsv4';
} else if (this.req?.digestConfig) { } else if (this.req?.digestConfig) {
return 'digest'; return 'digest';
} else if (this.headers?.['X-WSSE'] || this.req?.auth?.username) {
return 'wsse';
} else { } else {
return 'none'; return 'none';
} }
@ -172,6 +173,10 @@ class BrunoRequest {
disableParsingResponseJson() { disableParsingResponseJson() {
this.req.__brunoDisableParsingResponseJson = true; this.req.__brunoDisableParsingResponseJson = true;
} }
getExecutionMode() {
return this.req.__bruno__executionMode;
}
} }
module.exports = BrunoRequest; module.exports = BrunoRequest;

View File

@ -111,6 +111,12 @@ const addBrunoRequestShimToContext = (vm, req) => {
vm.setProp(reqObject, 'disableParsingResponseJson', disableParsingResponseJson); vm.setProp(reqObject, 'disableParsingResponseJson', disableParsingResponseJson);
disableParsingResponseJson.dispose(); disableParsingResponseJson.dispose();
let getExecutionMode = vm.newFunction('getExecutionMode', function () {
return marshallToVm(req.getExecutionMode(), vm);
});
vm.setProp(reqObject, 'getExecutionMode', getExecutionMode);
getExecutionMode.dispose();
vm.setProp(vm.global, 'req', reqObject); vm.setProp(vm.global, 'req', reqObject);
reqObject.dispose(); reqObject.dispose();
}; };

View File

@ -23,7 +23,7 @@ const { outdentString } = require('../../v1/src/utils');
*/ */
const grammar = ohm.grammar(`Bru { const grammar = ohm.grammar(`Bru {
BruFile = (meta | http | query | params | headers | auths | bodies | varsandassert | script | tests | docs)* BruFile = (meta | http | query | params | headers | auths | bodies | varsandassert | script | tests | docs)*
auths = authawsv4 | authbasic | authbearer | authdigest | authOAuth2 | authapikey auths = authawsv4 | authbasic | authbearer | authdigest | authOAuth2 | authwsse | authapikey
bodies = bodyjson | bodytext | bodyxml | bodysparql | bodygraphql | bodygraphqlvars | bodyforms | body bodies = bodyjson | bodytext | bodyxml | bodysparql | bodygraphql | bodygraphqlvars | bodyforms | body
bodyforms = bodyformurlencoded | bodymultipart bodyforms = bodyformurlencoded | bodymultipart
params = paramspath | paramsquery params = paramspath | paramsquery
@ -88,6 +88,7 @@ const grammar = ohm.grammar(`Bru {
authbearer = "auth:bearer" dictionary authbearer = "auth:bearer" dictionary
authdigest = "auth:digest" dictionary authdigest = "auth:digest" dictionary
authOAuth2 = "auth:oauth2" dictionary authOAuth2 = "auth:oauth2" dictionary
authwsse = "auth:wsse" dictionary
authapikey = "auth:apikey" dictionary authapikey = "auth:apikey" dictionary
body = "body" st* "{" nl* textblock tagend body = "body" st* "{" nl* textblock tagend
@ -484,6 +485,23 @@ const sem = grammar.createSemantics().addAttribute('ast', {
} }
}; };
}, },
authwsse(_1, dictionary) {
const auth = mapPairListToKeyValPairs(dictionary.ast, false);
const userKey = _.find(auth, { name: 'username' });
const secretKey = _.find(auth, { name: 'password' });
const username = userKey ? userKey.value : '';
const password = secretKey ? secretKey.value : '';
return {
auth: {
wsse: {
username,
password
}
}
};
},
authapikey(_1, dictionary) { authapikey(_1, dictionary) {
const auth = mapPairListToKeyValPairs(dictionary.ast, false); const auth = mapPairListToKeyValPairs(dictionary.ast, false);

View File

@ -4,7 +4,7 @@ const { outdentString } = require('../../v1/src/utils');
const grammar = ohm.grammar(`Bru { const grammar = ohm.grammar(`Bru {
BruFile = (meta | query | headers | auth | auths | vars | script | tests | docs)* BruFile = (meta | query | headers | auth | auths | vars | script | tests | docs)*
auths = authawsv4 | authbasic | authbearer | authdigest | authOAuth2 | authapikey auths = authawsv4 | authbasic | authbearer | authdigest | authOAuth2 | authwsse | authapikey
nl = "\\r"? "\\n" nl = "\\r"? "\\n"
st = " " | "\\t" st = " " | "\\t"
@ -43,6 +43,7 @@ const grammar = ohm.grammar(`Bru {
authbearer = "auth:bearer" dictionary authbearer = "auth:bearer" dictionary
authdigest = "auth:digest" dictionary authdigest = "auth:digest" dictionary
authOAuth2 = "auth:oauth2" dictionary authOAuth2 = "auth:oauth2" dictionary
authwsse = "auth:wsse" dictionary
authapikey = "auth:apikey" dictionary authapikey = "auth:apikey" dictionary
script = scriptreq | scriptres script = scriptreq | scriptres
@ -294,6 +295,21 @@ const sem = grammar.createSemantics().addAttribute('ast', {
} }
}; };
}, },
authwsse(_1, dictionary) {
const auth = mapPairListToKeyValPairs(dictionary.ast, false);
const userKey = _.find(auth, { name: 'username' });
const secretKey = _.find(auth, { name: 'password' });
const username = userKey ? userKey.value : '';
const password = secretKey ? secretKey.value : '';
return {
auth: {
wsse: {
username,
password
}
}
}
},
authapikey(_1, dictionary) { authapikey(_1, dictionary) {
const auth = mapPairListToKeyValPairs(dictionary.ast, false); const auth = mapPairListToKeyValPairs(dictionary.ast, false);

View File

@ -136,6 +136,15 @@ ${indentString(`username: ${auth?.basic?.username || ''}`)}
${indentString(`password: ${auth?.basic?.password || ''}`)} ${indentString(`password: ${auth?.basic?.password || ''}`)}
} }
`;
}
if (auth && auth.wsse) {
bru += `auth:wsse {
${indentString(`username: ${auth?.wsse?.username || ''}`)}
${indentString(`password: ${auth?.wsse?.password || ''}`)}
}
`; `;
} }

View File

@ -94,6 +94,15 @@ ${indentString(`username: ${auth.basic.username}`)}
${indentString(`password: ${auth.basic.password}`)} ${indentString(`password: ${auth.basic.password}`)}
} }
`;
}
if (auth && auth.wsse) {
bru += `auth:wsse {
${indentString(`username: ${auth.wsse.username}`)}
${indentString(`password: ${auth.wsse.password}`)}
}
`; `;
} }

View File

@ -17,6 +17,11 @@ auth:basic {
password: secret password: secret
} }
auth:wsse {
username: john
password: secret
}
auth:bearer { auth:bearer {
token: 123 token: 123
} }

View File

@ -31,6 +31,10 @@
"digest": { "digest": {
"username": "john", "username": "john",
"password": "secret" "password": "secret"
},
"wsse": {
"username": "john",
"password": "secret"
} }
}, },
"vars": { "vars": {

View File

@ -40,6 +40,11 @@ auth:basic {
password: secret password: secret
} }
auth:wsse {
username: john
password: secret
}
auth:bearer { auth:bearer {
token: 123 token: 123
} }

View File

@ -83,6 +83,10 @@
"scope": "read write", "scope": "read write",
"state": "807061d5f0be", "state": "807061d5f0be",
"pkce": false "pkce": false
},
"wsse": {
"username": "john",
"password": "secret"
} }
}, },
"body": { "body": {

View File

@ -106,6 +106,13 @@ const authBasicSchema = Yup.object({
.noUnknown(true) .noUnknown(true)
.strict(); .strict();
const authWsseSchema = Yup.object({
username: Yup.string().nullable(),
password: Yup.string().nullable()
})
.noUnknown(true)
.strict();
const authBearerSchema = Yup.object({ const authBearerSchema = Yup.object({
token: Yup.string().nullable() token: Yup.string().nullable()
}) })
@ -119,6 +126,14 @@ const authDigestSchema = Yup.object({
.noUnknown(true) .noUnknown(true)
.strict(); .strict();
const authApiKeySchema = Yup.object({
key: Yup.string().nullable(),
value: Yup.string().nullable(),
placement: Yup.string().oneOf(['header', 'queryparams']).nullable()
})
.noUnknown(true)
.strict();
const oauth2Schema = Yup.object({ const oauth2Schema = Yup.object({
grantType: Yup.string() grantType: Yup.string()
.oneOf(['client_credentials', 'password', 'authorization_code']) .oneOf(['client_credentials', 'password', 'authorization_code'])
@ -177,21 +192,16 @@ const oauth2Schema = Yup.object({
.noUnknown(true) .noUnknown(true)
.strict(); .strict();
const authApiKeySchema = Yup.object({
key: Yup.string().nullable(),
value: Yup.string().nullable(),
placement: Yup.string().oneOf(['header', 'queryparams']).nullable()
});
const authSchema = Yup.object({ const authSchema = Yup.object({
mode: Yup.string() mode: Yup.string()
.oneOf(['inherit', 'none', 'awsv4', 'basic', 'bearer', 'digest', 'oauth2', 'apikey']) .oneOf(['inherit', 'none', 'awsv4', 'basic', 'bearer', 'digest', 'oauth2', 'wsse', 'apikey'])
.required('mode is required'), .required('mode is required'),
awsv4: authAwsV4Schema.nullable(), awsv4: authAwsV4Schema.nullable(),
basic: authBasicSchema.nullable(), basic: authBasicSchema.nullable(),
bearer: authBearerSchema.nullable(), bearer: authBearerSchema.nullable(),
digest: authDigestSchema.nullable(), digest: authDigestSchema.nullable(),
oauth2: oauth2Schema.nullable(), oauth2: oauth2Schema.nullable(),
wsse: authWsseSchema.nullable(),
apikey: authApiKeySchema.nullable() apikey: authApiKeySchema.nullable()
}) })
.noUnknown(true) .noUnknown(true)

View File

@ -15,7 +15,7 @@
"bypassProxy": "" "bypassProxy": ""
}, },
"scripts": { "scripts": {
"moduleWhitelist": ["crypto", "buffer"], "moduleWhitelist": ["crypto", "buffer", "form-data"],
"filesystemAccess": { "filesystemAccess": {
"allow": true "allow": true
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 795 B

View File

@ -0,0 +1,23 @@
meta {
name: echo form-url-encoded
type: http
seq: 9
}
post {
url: {{echo-host}}
body: formUrlEncoded
auth: none
}
body:form-urlencoded {
form-data-key: {{form-data-key}}
}
script:pre-request {
bru.setVar('form-data-key', 'form-data-value');
}
assert {
res.body: eq form-data-key=form-data-value
}

View File

@ -0,0 +1,22 @@
meta {
name: echo multipart via scripting
type: http
seq: 10
}
post {
url: {{echo-host}}
body: multipartForm
auth: none
}
assert {
res.body: contains form-data-value
}
script:pre-request {
const FormData = require("form-data");
const form = new FormData();
form.append('form-data-key', 'form-data-value');
req.setBody(form);
}

View File

@ -0,0 +1,24 @@
meta {
name: echo multipart
type: http
seq: 8
}
post {
url: {{echo-host}}
body: multipartForm
auth: none
}
body:multipart-form {
foo: {{form-data-key}}
file: @file(bruno.png)
}
assert {
res.body: contains form-data-value
}
script:pre-request {
bru.setVar('form-data-key', 'form-data-value');
}

View File

@ -7,4 +7,5 @@ vars {
bark: {{process.env.PROC_ENV_VAR}} bark: {{process.env.PROC_ENV_VAR}}
foo: bar foo: bar
testSetEnvVar: bruno-29653 testSetEnvVar: bruno-29653
echo-host: https://echo.usebruno.com
} }

View File

@ -3,6 +3,7 @@ const router = express.Router();
const authBearer = require('./bearer'); const authBearer = require('./bearer');
const authBasic = require('./basic'); const authBasic = require('./basic');
const authWsse = require('./wsse');
const authCookie = require('./cookie'); const authCookie = require('./cookie');
const authOAuth2PasswordCredentials = require('./oauth2/passwordCredentials'); const authOAuth2PasswordCredentials = require('./oauth2/passwordCredentials');
const authOAuth2AuthorizationCode = require('./oauth2/authorizationCode'); const authOAuth2AuthorizationCode = require('./oauth2/authorizationCode');
@ -13,6 +14,7 @@ router.use('/oauth2/authorization_code', authOAuth2AuthorizationCode);
router.use('/oauth2/client_credentials', authOAuth2ClientCredentials); router.use('/oauth2/client_credentials', authOAuth2ClientCredentials);
router.use('/bearer', authBearer); router.use('/bearer', authBearer);
router.use('/basic', authBasic); router.use('/basic', authBasic);
router.use('/wsse', authWsse);
router.use('/cookie', authCookie); router.use('/cookie', authCookie);
module.exports = router; module.exports = router;

View File

@ -0,0 +1,70 @@
'use strict';
const express = require('express');
const router = express.Router();
const crypto = require('crypto');
function sha256(data) {
return crypto.createHash('sha256').update(data).digest('base64');
}
function validateWSSE(req, res, next) {
const wsseHeader = req.headers['x-wsse'];
if (!wsseHeader) {
return unauthorized(res, 'WSSE header is missing');
}
const regex = /UsernameToken Username="(.+?)", PasswordDigest="(.+?)", (?:Nonce|nonce)="(.+?)", Created="(.+?)"/;
const matches = wsseHeader.match(regex);
if (!matches) {
return unauthorized(res, 'Invalid WSSE header format');
}
const [_, username, passwordDigest, nonce, created] = matches;
const expectedPassword = 'bruno'; // Ideally store in a config or env variable
const expectedDigest = sha256(nonce + created + expectedPassword);
if (passwordDigest !== expectedDigest) {
return unauthorized(res, 'Invalid credentials');
}
next();
}
// Helper to respond with an unauthorized SOAP fault
function unauthorized(res, message) {
const faultResponse = `
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:web="http://webservice/">
<soapenv:Header/>
<soapenv:Body>
<soapenv:Fault>
<faultcode>soapenv:Client</faultcode>
<faultstring>${message}</faultstring>
</soapenv:Fault>
</soapenv:Body>
</soapenv:Envelope>
`;
res.status(401).set('Content-Type', 'text/xml');
res.send(faultResponse);
}
const responses = {
success: `
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:web="http://webservice/">
<soapenv:Header/>
<soapenv:Body>
<web:response>
<web:result>Success</web:result>
</web:response>
</soapenv:Body>
</soapenv:Envelope>
`
};
router.post('/protected', validateWSSE, (req, res) => {
res.set('Content-Type', 'text/xml');
res.send(responses.success);
});
module.exports = router;