diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..a7c8969da --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,9 @@ +# Description + + + +# Contribution Checklist: + +- [ ] **The pull request does not introduce any breaking changes** +- [ ] **I have read the [contribution guidelines](https://github.com/usebruno/bruno/blob/main/contributing.md).** +- [ ] **Create an issue and link to the pull request.** diff --git a/packages/bruno-app/src/components/CollectionSettings/Auth/AwsV4Auth/index.js b/packages/bruno-app/src/components/CollectionSettings/Auth/AwsV4Auth/index.js index 1fe35eea0..bc9cb67b5 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Auth/AwsV4Auth/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/Auth/AwsV4Auth/index.js @@ -12,7 +12,6 @@ const AwsV4Auth = ({ collection }) => { const { storedTheme } = useTheme(); const awsv4Auth = get(collection, 'root.request.auth.awsv4', {}); - console.log('saved auth', awsv4Auth); const handleSave = () => dispatch(saveCollectionRoot(collection.uid)); diff --git a/packages/bruno-app/src/components/CollectionSettings/ClientCertSettings/StyledWrapper.js b/packages/bruno-app/src/components/CollectionSettings/ClientCertSettings/StyledWrapper.js new file mode 100644 index 000000000..625bc98e6 --- /dev/null +++ b/packages/bruno-app/src/components/CollectionSettings/ClientCertSettings/StyledWrapper.js @@ -0,0 +1,43 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + .settings-label { + width: 90px; + } + + .certificate-icon { + color: ${(props) => props.theme.colors.text.yellow}; + } + + input { + width: 300px; + } + + .available-certificates { + background-color: ${(props) => props.theme.requestTabPanel.url.bg}; + + button.remove-certificate { + color: ${(props) => props.theme.colors.text.danger}; + } + } + + .textbox { + border: 1px solid #ccc; + padding: 0.15rem 0.45rem; + box-shadow: none; + border-radius: 0px; + outline: none; + box-shadow: none; + transition: border-color ease-in-out 0.1s; + border-radius: 3px; + background-color: ${(props) => props.theme.modal.input.bg}; + border: 1px solid ${(props) => props.theme.modal.input.border}; + + &:focus { + border: solid 1px ${(props) => props.theme.modal.input.focusBorder} !important; + outline: none !important; + } + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/CollectionSettings/ClientCertSettings/index.js b/packages/bruno-app/src/components/CollectionSettings/ClientCertSettings/index.js new file mode 100644 index 000000000..235e274f5 --- /dev/null +++ b/packages/bruno-app/src/components/CollectionSettings/ClientCertSettings/index.js @@ -0,0 +1,130 @@ +import React from 'react'; +import { IconCertificate, IconTrash, IconWorld } from '@tabler/icons'; +import { useFormik } from 'formik'; +import { uuid } from 'utils/common'; +import * as Yup from 'yup'; + +import StyledWrapper from './StyledWrapper'; + +const ClientCertSettings = ({ clientCertConfig, onUpdate, onRemove }) => { + const formik = useFormik({ + initialValues: { + domain: '', + certFilePath: '', + keyFilePath: '', + passphrase: '' + }, + validationSchema: Yup.object({ + domain: Yup.string().required(), + certFilePath: Yup.string().required(), + keyFilePath: Yup.string().required(), + passphrase: Yup.string() + }), + onSubmit: (values) => { + onUpdate(values); + } + }); + + const getFile = (e) => { + formik.values[e.name] = e.files[0].path; + }; + + return ( + +
+ Client Certificates +
+ + +

Add Client Certicate

+
+
+ + + {formik.touched.domain && formik.errors.domain ? ( +
{formik.errors.domain}
+ ) : null} +
+
+ + getFile(e.target)} + /> + {formik.touched.certFilePath && formik.errors.certFilePath ? ( +
{formik.errors.certFilePath}
+ ) : null} +
+
+ + getFile(e.target)} + /> + {formik.touched.keyFilePath && formik.errors.keyFilePath ? ( +
{formik.errors.keyFilePath}
+ ) : null} +
+
+ + + {formik.touched.passphrase && formik.errors.passphrase ? ( +
{formik.errors.passphrase}
+ ) : null} +
+
+ +
+
+
+ ); +}; + +export default ClientCertSettings; diff --git a/packages/bruno-app/src/components/CollectionSettings/index.js b/packages/bruno-app/src/components/CollectionSettings/index.js index fe85005f1..d73b075d0 100644 --- a/packages/bruno-app/src/components/CollectionSettings/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/index.js @@ -7,6 +7,7 @@ import { updateBrunoConfig } from 'providers/ReduxStore/slices/collections/actio import { updateSettingsSelectedTab } from 'providers/ReduxStore/slices/collections'; import { useDispatch } from 'react-redux'; import ProxySettings from './ProxySettings'; +import ClientCertSettings from './ClientCertSettings'; import Headers from './Headers'; import Auth from './Auth'; import Script from './Script'; @@ -28,6 +29,8 @@ const CollectionSettings = ({ collection }) => { const proxyConfig = get(collection, 'brunoConfig.proxy', {}); + const clientCertConfig = get(collection, 'brunoConfig.clientCertificates.certs', []); + const onProxySettingsUpdate = (config) => { const brunoConfig = cloneDeep(collection.brunoConfig); brunoConfig.proxy = config; @@ -38,6 +41,33 @@ const CollectionSettings = ({ collection }) => { .catch((err) => console.log(err) && toast.error('Failed to update collection settings')); }; + const onClientCertSettingsUpdate = (config) => { + const brunoConfig = cloneDeep(collection.brunoConfig); + if (!brunoConfig.clientCertificates) { + brunoConfig.clientCertificates = { + enabled: true, + certs: [config] + }; + } else { + brunoConfig.clientCertificates.certs.push(config); + } + dispatch(updateBrunoConfig(brunoConfig, collection.uid)) + .then(() => { + toast.success('Collection settings updated successfully'); + }) + .catch((err) => console.log(err) && toast.error('Failed to update collection settings')); + }; + + const onClientCertSettingsRemove = (config) => { + const brunoConfig = cloneDeep(collection.brunoConfig); + brunoConfig.clientCertificates = brunoConfig.clientCertificates.filter((item) => item.domain != config.domain); + dispatch(updateBrunoConfig(brunoConfig, collection.uid)) + .then(() => { + toast.success('Collection settings updated successfully'); + }) + .catch((err) => console.log(err) && toast.error('Failed to update collection settings')); + }; + const getTabPanel = (tab) => { switch (tab) { case 'headers': { @@ -55,6 +85,15 @@ const CollectionSettings = ({ collection }) => { case 'proxy': { return ; } + case 'clientCert': { + return ( + + ); + } case 'docs': { return ; } @@ -85,11 +124,16 @@ const CollectionSettings = ({ collection }) => {
setTab('proxy')}> Proxy
+
setTab('clientCert')}> + Client Certificates +
setTab('docs')}> Docs
-
{getTabPanel(tab)}
+
+ {getTabPanel(tab)} +
); }; diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/CreateEnvironment/index.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/CreateEnvironment/index.js index c0ca7f5ec..e6947bd3a 100644 --- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/CreateEnvironment/index.js +++ b/packages/bruno-app/src/components/Environments/EnvironmentSettings/CreateEnvironment/index.js @@ -1,6 +1,6 @@ import React, { useEffect, useRef } from 'react'; -import Portal from 'components/Portal/index'; -import Modal from 'components/Modal/index'; +import Portal from 'components/Portal'; +import Modal from 'components/Modal'; import toast from 'react-hot-toast'; import { useFormik } from 'formik'; import { addEnvironment } from 'providers/ReduxStore/slices/collections/actions'; diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/StyledWrapper.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/StyledWrapper.js index 722a15db1..687cde46c 100644 --- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/StyledWrapper.js +++ b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/StyledWrapper.js @@ -10,6 +10,7 @@ const StyledWrapper = styled.div` background-color: ${(props) => props.theme.collection.environment.settings.sidebar.bg}; border-right: solid 1px ${(props) => props.theme.collection.environment.settings.sidebar.borderRight}; min-height: 400px; + height: 100%; } .environment-item { @@ -35,7 +36,8 @@ const StyledWrapper = styled.div` } } - .btn-create-environment { + .btn-create-environment, + .btn-import-environment { padding: 8px 10px; cursor: pointer; border-bottom: none; @@ -47,6 +49,10 @@ const StyledWrapper = styled.div` } } } + + .btn-import-environment { + color: ${(props) => props.theme.colors.text.muted}; + } `; export default StyledWrapper; diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/index.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/index.js index b80cd92a5..44e18455f 100644 --- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/index.js +++ b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/index.js @@ -1,14 +1,19 @@ import React, { useEffect, useState, forwardRef, useRef } from 'react'; import { findEnvironmentInCollection } from 'utils/collections'; +import toast from 'react-hot-toast'; +import { toastError } from 'utils/common/error'; import usePrevious from 'hooks/usePrevious'; import EnvironmentDetails from './EnvironmentDetails'; -import CreateEnvironment from '../CreateEnvironment/index'; +import CreateEnvironment from '../CreateEnvironment'; +import { IconUpload } from '@tabler/icons'; +import ImportEnvironment from '../ImportEnvironment'; import StyledWrapper from './StyledWrapper'; const EnvironmentList = ({ collection }) => { const { environments } = collection; const [selectedEnvironment, setSelectedEnvironment] = useState(null); const [openCreateModal, setOpenCreateModal] = useState(false); + const [openImportModal, setOpenImportModal] = useState(false); const envUids = environments ? environments.map((env) => env.uid) : []; const prevEnvUids = usePrevious(envUids); @@ -48,9 +53,10 @@ const EnvironmentList = ({ collection }) => { return ( {openCreateModal && setOpenCreateModal(false)} />} + {openImportModal && setOpenImportModal(false)} />}
-
+
{environments && environments.length && environments.map((env) => ( @@ -65,6 +71,11 @@ const EnvironmentList = ({ collection }) => {
setOpenCreateModal(true)}> + Create
+ +
setOpenImportModal(true)}> + + Import +
diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/ImportEnvironment/index.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/ImportEnvironment/index.js new file mode 100644 index 000000000..5caba79b2 --- /dev/null +++ b/packages/bruno-app/src/components/Environments/EnvironmentSettings/ImportEnvironment/index.js @@ -0,0 +1,39 @@ +import React from 'react'; +import Portal from 'components/Portal'; +import toast from 'react-hot-toast'; +import { useDispatch } from 'react-redux'; +import importPostmanEnvironment from 'utils/importers/postman-environment'; +import { importEnvironment } from 'providers/ReduxStore/slices/collections/actions'; +import { toastError } from 'utils/common/error'; +import Modal from 'components/Modal'; + +const ImportEnvironment = ({ onClose, collection }) => { + const dispatch = useDispatch(); + + const handleImportPostmanEnvironment = () => { + importPostmanEnvironment() + .then((environment) => { + dispatch(importEnvironment(environment.name, environment.variables, collection.uid)) + .then(() => { + toast.success('Environment imported successfully'); + onClose(); + }) + .catch(() => toast.error('An error occurred while importing the environment')); + }) + .catch((err) => toastError(err, 'Postman Import environment failed')); + }; + + return ( + + +
+
+ Postman Environment +
+
+
+
+ ); +}; + +export default ImportEnvironment; diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/index.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/index.js index 855e0fb31..6daccc374 100644 --- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/index.js +++ b/packages/bruno-app/src/components/Environments/EnvironmentSettings/index.js @@ -3,10 +3,12 @@ import React, { useState } from 'react'; import CreateEnvironment from './CreateEnvironment'; import EnvironmentList from './EnvironmentList'; import StyledWrapper from './StyledWrapper'; +import ImportEnvironment from './ImportEnvironment'; const EnvironmentSettings = ({ collection, onClose }) => { const { environments } = collection; const [openCreateModal, setOpenCreateModal] = useState(false); + const [openImportModal, setOpenImportModal] = useState(false); if (!environments || !environments.length) { return ( @@ -20,13 +22,23 @@ const EnvironmentSettings = ({ collection, onClose }) => { hideCancel={true} > {openCreateModal && setOpenCreateModal(false)} />} -
+ {openImportModal && setOpenImportModal(false)} />} +

No environments found!

+ + Or + +
diff --git a/packages/bruno-app/src/components/Preferences/Font/index.js b/packages/bruno-app/src/components/Preferences/Font/index.js index bae23e723..2f27fea8b 100644 --- a/packages/bruno-app/src/components/Preferences/Font/index.js +++ b/packages/bruno-app/src/components/Preferences/Font/index.js @@ -30,18 +30,16 @@ const Font = ({ close }) => { return ( -
- -
+
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js index 80c823454..38e3c30ed 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js @@ -722,6 +722,32 @@ export const addEnvironment = (name, collectionUid) => (dispatch, getState) => { }); }; +export const importEnvironment = (name, variables, collectionUid) => (dispatch, getState) => { + return new Promise((resolve, reject) => { + const state = getState(); + const collection = findCollectionByUid(state.collections.collections, collectionUid); + if (!collection) { + return reject(new Error('Collection not found')); + } + + ipcRenderer + .invoke('renderer:create-environment', collection.pathname, name, variables) + .then( + dispatch( + updateLastAction({ + collectionUid, + lastAction: { + type: 'ADD_ENVIRONMENT', + payload: name + } + }) + ) + ) + .then(resolve) + .catch(reject); + }); +}; + export const copyEnvironment = (name, baseEnvUid, collectionUid) => (dispatch, getState) => { return new Promise((resolve, reject) => { const state = getState(); @@ -736,7 +762,7 @@ export const copyEnvironment = (name, baseEnvUid, collectionUid) => (dispatch, g } ipcRenderer - .invoke('renderer:copy-environment', collection.pathname, name, baseEnv.variables) + .invoke('renderer:create-environment', collection.pathname, name, baseEnv.variables) .then( dispatch( updateLastAction({ diff --git a/packages/bruno-app/src/utils/importers/postman-environment.js b/packages/bruno-app/src/utils/importers/postman-environment.js new file mode 100644 index 000000000..61c62311c --- /dev/null +++ b/packages/bruno-app/src/utils/importers/postman-environment.js @@ -0,0 +1,71 @@ +import each from 'lodash/each'; +import fileDialog from 'file-dialog'; +import { BrunoError } from 'utils/common/error'; + +const readFile = (files) => { + return new Promise((resolve, reject) => { + const fileReader = new FileReader(); + fileReader.onload = (e) => resolve(e.target.result); + fileReader.onerror = (err) => reject(err); + fileReader.readAsText(files[0]); + }); +}; + +const isSecret = (type) => { + return type === 'secret'; +}; + +const importPostmanEnvironmentVariables = (brunoEnvironment, values) => { + brunoEnvironment.variables = brunoEnvironment.variables || []; + + each(values, (i) => { + const brunoEnvironmentVariable = { + name: i.key, + value: i.value, + enabled: i.enabled, + secret: isSecret(i.type) + }; + + brunoEnvironment.variables.push(brunoEnvironmentVariable); + }); +}; + +const importPostmanEnvironment = (environment) => { + const brunoEnvironment = { + name: environment.name, + variables: [] + }; + + importPostmanEnvironmentVariables(brunoEnvironment, environment.values); + return brunoEnvironment; +}; + +const parsePostmanEnvironment = (str) => { + return new Promise((resolve, reject) => { + try { + let environment = JSON.parse(str); + return resolve(importPostmanEnvironment(environment)); + } catch (err) { + console.log(err); + if (err instanceof BrunoError) { + return reject(err); + } + return reject(new BrunoError('Unable to parse the postman environment json file')); + } + }); +}; + +const importEnvironment = () => { + return new Promise((resolve, reject) => { + fileDialog({ accept: 'application/json' }) + .then(readFile) + .then(parsePostmanEnvironment) + .then((environment) => resolve(environment)) + .catch((err) => { + console.log(err); + reject(new BrunoError('Import Environment failed')); + }); + }); +}; + +export default importEnvironment; diff --git a/packages/bruno-electron/package.json b/packages/bruno-electron/package.json index ffe266f3e..70a74326b 100644 --- a/packages/bruno-electron/package.json +++ b/packages/bruno-electron/package.json @@ -1,5 +1,5 @@ { - "version": "v0.24.0", + "version": "v0.25.0", "name": "bruno", "description": "Opensource API Client for Exploring and Testing APIs", "homepage": "https://www.usebruno.com", diff --git a/packages/bruno-electron/src/ipc/collection.js b/packages/bruno-electron/src/ipc/collection.js index afddb8696..e3f896763 100644 --- a/packages/bruno-electron/src/ipc/collection.js +++ b/packages/bruno-electron/src/ipc/collection.js @@ -135,7 +135,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection }); // create environment - ipcMain.handle('renderer:create-environment', async (event, collectionPathname, name) => { + ipcMain.handle('renderer:create-environment', async (event, collectionPathname, name, variables) => { try { const envDirPath = path.join(collectionPathname, 'environments'); if (!fs.existsSync(envDirPath)) { @@ -147,31 +147,17 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection throw new Error(`environment: ${envFilePath} already exists`); } - const content = envJsonToBru({ - variables: [] - }); - await writeFile(envFilePath, content); - } catch (error) { - return Promise.reject(error); - } - }); + const environment = { + name: name, + variables: variables || [] + }; - // copy environment - ipcMain.handle('renderer:copy-environment', async (event, collectionPathname, name, baseVariables) => { - try { - const envDirPath = path.join(collectionPathname, 'environments'); - if (!fs.existsSync(envDirPath)) { - await createDirectory(envDirPath); + if (envHasSecrets(environment)) { + environmentSecretsStore.storeEnvSecrets(collectionPathname, environment); } - const envFilePath = path.join(envDirPath, `${name}.bru`); - if (fs.existsSync(envFilePath)) { - throw new Error(`environment: ${envFilePath} already exists`); - } + const content = envJsonToBru(environment); - const content = envJsonToBru({ - variables: baseVariables - }); await writeFile(envFilePath, content); } catch (error) { return Promise.reject(error); diff --git a/packages/bruno-electron/src/ipc/network/index.js b/packages/bruno-electron/src/ipc/network/index.js index a63aba0dc..9562f2cdf 100644 --- a/packages/bruno-electron/src/ipc/network/index.js +++ b/packages/bruno-electron/src/ipc/network/index.js @@ -1,8 +1,8 @@ const os = require('os'); +const fs = require('fs'); const qs = require('qs'); const https = require('https'); const axios = require('axios'); -const fs = require('fs'); const decomment = require('decomment'); const Mustache = require('mustache'); const FormData = require('form-data'); @@ -100,8 +100,37 @@ const configureRequest = async (collectionUid, request, envVars, collectionVaria } } - // proxy configuration const brunoConfig = getBrunoConfig(collectionUid); + const interpolationOptions = { + envVars, + collectionVariables, + processEnvVars + }; + + // client certificate config + const clientCertConfig = get(brunoConfig, 'clientCertificates.certs', []); + + for (clientCert of clientCertConfig) { + const domain = interpolateString(clientCert.domain, interpolationOptions); + const certFilePath = interpolateString(clientCert.certFilePath, interpolationOptions); + const keyFilePath = interpolateString(clientCert.keyFilePath, interpolationOptions); + if (domain && certFilePath && keyFilePath) { + const hostRegex = '^https:\\/\\/' + domain.replaceAll('.', '\\.').replaceAll('*', '.*'); + + if (request.url.match(hostRegex)) { + try { + httpsAgentRequestFields['cert'] = fs.readFileSync(certFilePath); + httpsAgentRequestFields['key'] = fs.readFileSync(keyFilePath); + } catch (err) { + console.log('Error reading cert/key file', err); + } + httpsAgentRequestFields['passphrase'] = interpolateString(clientCert.passphrase, interpolationOptions); + break; + } + } + } + + // proxy configuration let proxyConfig = get(brunoConfig, 'proxy', {}); let proxyEnabled = get(proxyConfig, 'enabled', 'disabled'); if (proxyEnabled === 'global') { @@ -157,6 +186,10 @@ const configureRequest = async (collectionUid, request, envVars, collectionVaria delete request.awsv4config; } + const preferences = getPreferences(); + const timeout = get(preferences, 'request.timeout', 0); + request.timeout = timeout; + return axiosInstance; }; @@ -516,6 +549,11 @@ const registerNetworkIpc = (mainWindow) => { const collectionRoot = get(collection, 'root', {}); const preparedRequest = prepareGqlIntrospectionRequest(endpoint, envVars, request, collectionRoot); + const preferences = getPreferences(); + const timeout = get(preferences, 'request.timeout', 0); + request.timeout = timeout; + const sslVerification = get(preferences, 'request.sslVerification', true); + if (!preferences.isTlsVerification()) { request.httpsAgent = new https.Agent({ rejectUnauthorized: false diff --git a/packages/bruno-electron/src/store/preferences.js b/packages/bruno-electron/src/store/preferences.js index ae462f646..3606086b3 100644 --- a/packages/bruno-electron/src/store/preferences.js +++ b/packages/bruno-electron/src/store/preferences.js @@ -11,7 +11,7 @@ const { get } = require('lodash'); const defaultPreferences = { request: { sslVerification: true, - caCert: '' + timeout: 0 }, font: { codeFont: 'default' @@ -32,7 +32,8 @@ const defaultPreferences = { const preferencesSchema = Yup.object().shape({ request: Yup.object().shape({ - sslVerification: Yup.boolean() + sslVerification: Yup.boolean(), + timeout: Yup.number() }), font: Yup.object().shape({ codeFont: Yup.string().nullable() @@ -104,8 +105,8 @@ const preferences = { return get(getPreferences(), 'request.sslVerification', true); }, - getCaCert: () => { - return get(getPreferences(), 'request.cacert'); + getTimeout: () => { + return get(getPreferences(), 'request.timeout'); }, getProxyConfig: () => { diff --git a/packages/bruno-js/src/bruno-request.js b/packages/bruno-js/src/bruno-request.js index 099d35111..afbf97873 100644 --- a/packages/bruno-js/src/bruno-request.js +++ b/packages/bruno-js/src/bruno-request.js @@ -5,6 +5,7 @@ class BrunoRequest { this.method = req.method; this.headers = req.headers; this.body = req.data; + this.timeout = req.timeout; } getUrl() { @@ -50,6 +51,14 @@ class BrunoRequest { setMaxRedirects(maxRedirects) { this.req.maxRedirects = maxRedirects; } + + getTimeout() { + return this.req.timeout; + } + + setTimeout(timeout) { + this.req.timeout = timeout; + } } module.exports = BrunoRequest;