mirror of
https://github.com/usebruno/bruno.git
synced 2024-11-22 07:53:34 +01:00
Merge pull request #817 from drinkbird/feature/digestauth
Add Digest Auth Support #119
This commit is contained in:
commit
64923e47a2
@ -61,6 +61,15 @@ const AuthMode = ({ collection }) => {
|
||||
>
|
||||
Bearer Token
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
onModeChange('digest');
|
||||
}}
|
||||
>
|
||||
Digest Auth
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
|
@ -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;
|
@ -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 DigestAuth = ({ collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
|
||||
const digestAuth = get(collection, 'root.request.auth.digest', {});
|
||||
|
||||
const handleSave = () => dispatch(saveCollectionRoot(collection.uid));
|
||||
|
||||
const handleUsernameChange = (username) => {
|
||||
dispatch(
|
||||
updateCollectionAuth({
|
||||
mode: 'digest',
|
||||
collectionUid: collection.uid,
|
||||
content: {
|
||||
username: username,
|
||||
password: digestAuth.password
|
||||
}
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handlePasswordChange = (password) => {
|
||||
dispatch(
|
||||
updateCollectionAuth({
|
||||
mode: 'digest',
|
||||
collectionUid: collection.uid,
|
||||
content: {
|
||||
username: digestAuth.username,
|
||||
password: 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={digestAuth.username || ''}
|
||||
theme={storedTheme}
|
||||
onSave={handleSave}
|
||||
onChange={(val) => handleUsernameChange(val)}
|
||||
collection={collection}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="block font-medium mb-2">Password</label>
|
||||
<div className="single-line-editor-wrapper">
|
||||
<SingleLineEditor
|
||||
value={digestAuth.password || ''}
|
||||
theme={storedTheme}
|
||||
onSave={handleSave}
|
||||
onChange={(val) => handlePasswordChange(val)}
|
||||
collection={collection}
|
||||
/>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default DigestAuth;
|
@ -5,6 +5,7 @@ import AuthMode from './AuthMode';
|
||||
import AwsV4Auth from './AwsV4Auth';
|
||||
import BearerAuth from './BearerAuth';
|
||||
import BasicAuth from './BasicAuth';
|
||||
import DigestAuth from './DigestAuth';
|
||||
import { saveCollectionRoot } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
@ -25,6 +26,9 @@ const Auth = ({ collection }) => {
|
||||
case 'bearer': {
|
||||
return <BearerAuth collection={collection} />;
|
||||
}
|
||||
case 'digest': {
|
||||
return <DigestAuth collection={collection} />;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -62,6 +62,15 @@ const AuthMode = ({ item, collection }) => {
|
||||
>
|
||||
Bearer Token
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
onModeChange('digest');
|
||||
}}
|
||||
>
|
||||
Digest Auth
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
|
@ -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;
|
@ -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 DigestAuth = ({ item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
|
||||
const digestAuth = item.draft ? get(item, 'draft.request.auth.digest', {}) : get(item, 'request.auth.digest', {});
|
||||
|
||||
const handleRun = () => dispatch(sendRequest(item, collection.uid));
|
||||
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
|
||||
|
||||
const handleUsernameChange = (username) => {
|
||||
dispatch(
|
||||
updateAuth({
|
||||
mode: 'digest',
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
content: {
|
||||
username: username,
|
||||
password: digestAuth.password
|
||||
}
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handlePasswordChange = (password) => {
|
||||
dispatch(
|
||||
updateAuth({
|
||||
mode: 'digest',
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
content: {
|
||||
username: digestAuth.username,
|
||||
password: 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={digestAuth.username || ''}
|
||||
theme={storedTheme}
|
||||
onSave={handleSave}
|
||||
onChange={(val) => handleUsernameChange(val)}
|
||||
onRun={handleRun}
|
||||
collection={collection}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="block font-medium mb-2">Password</label>
|
||||
<div className="single-line-editor-wrapper">
|
||||
<SingleLineEditor
|
||||
value={digestAuth.password || ''}
|
||||
theme={storedTheme}
|
||||
onSave={handleSave}
|
||||
onChange={(val) => handlePasswordChange(val)}
|
||||
onRun={handleRun}
|
||||
collection={collection}
|
||||
/>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default DigestAuth;
|
@ -4,6 +4,7 @@ import AuthMode from './AuthMode';
|
||||
import AwsV4Auth from './AwsV4Auth';
|
||||
import BearerAuth from './BearerAuth';
|
||||
import BasicAuth from './BasicAuth';
|
||||
import DigestAuth from './DigestAuth';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const Auth = ({ item, collection }) => {
|
||||
@ -20,6 +21,9 @@ const Auth = ({ item, collection }) => {
|
||||
case 'bearer': {
|
||||
return <BearerAuth collection={collection} item={item} />;
|
||||
}
|
||||
case 'digest': {
|
||||
return <DigestAuth collection={collection} item={item} />;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -388,6 +388,10 @@ export const collectionsSlice = createSlice({
|
||||
item.draft.request.auth.mode = 'basic';
|
||||
item.draft.request.auth.basic = action.payload.content;
|
||||
break;
|
||||
case 'digest':
|
||||
item.draft.request.auth.mode = 'digest';
|
||||
item.draft.request.auth.digest = action.payload.content;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -976,6 +980,9 @@ export const collectionsSlice = createSlice({
|
||||
case 'basic':
|
||||
set(collection, 'root.request.auth.basic', action.payload.content);
|
||||
break;
|
||||
case 'digest':
|
||||
set(collection, 'root.request.auth.digest', action.payload.content);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -494,6 +494,10 @@ export const humanizeRequestAuthMode = (mode) => {
|
||||
label = 'Bearer Token';
|
||||
break;
|
||||
}
|
||||
case 'digest': {
|
||||
label = 'Digest Auth';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return label;
|
||||
|
@ -80,7 +80,8 @@ const transformInsomniaRequestItem = (request, index, allRequests) => {
|
||||
auth: {
|
||||
mode: 'none',
|
||||
basic: null,
|
||||
bearer: null
|
||||
bearer: null,
|
||||
digest: null
|
||||
},
|
||||
headers: [],
|
||||
params: [],
|
||||
|
@ -69,7 +69,8 @@ const transformOpenapiRequestItem = (request) => {
|
||||
auth: {
|
||||
mode: 'none',
|
||||
basic: null,
|
||||
bearer: null
|
||||
bearer: null,
|
||||
digest: null
|
||||
},
|
||||
headers: [],
|
||||
params: [],
|
||||
|
79
packages/bruno-electron/src/ipc/network/digestauth-helper.js
Normal file
79
packages/bruno-electron/src/ipc/network/digestauth-helper.js
Normal file
@ -0,0 +1,79 @@
|
||||
const crypto = require('crypto');
|
||||
|
||||
function isStrPresent(str) {
|
||||
return str && str !== '' && str !== 'undefined';
|
||||
}
|
||||
|
||||
function stripQuotes(str) {
|
||||
return str.replace(/"/g, '');
|
||||
}
|
||||
|
||||
function containsDigestHeader(response) {
|
||||
const authHeader = response?.headers?.['www-authenticate'];
|
||||
return authHeader ? authHeader.trim().toLowerCase().startsWith('digest') : false;
|
||||
}
|
||||
|
||||
function containsAuthorizationHeader(originalRequest) {
|
||||
return Boolean(originalRequest.headers['Authorization']);
|
||||
}
|
||||
|
||||
function md5(input) {
|
||||
return crypto.createHash('md5').update(input).digest('hex');
|
||||
}
|
||||
|
||||
function addDigestInterceptor(axiosInstance, request) {
|
||||
const { username, password } = request.digestConfig;
|
||||
|
||||
console.debug(request);
|
||||
|
||||
if (!isStrPresent(username) || !isStrPresent(password)) {
|
||||
console.warn('Required Digest Auth fields are not present');
|
||||
return;
|
||||
}
|
||||
|
||||
axiosInstance.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
if (
|
||||
error.response?.status === 401 &&
|
||||
containsDigestHeader(error.response) &&
|
||||
!containsAuthorizationHeader(originalRequest)
|
||||
) {
|
||||
console.debug(error.response.headers['www-authenticate']);
|
||||
|
||||
const authDetails = error.response.headers['www-authenticate']
|
||||
.split(', ')
|
||||
.map((v) => v.split('=').map(stripQuotes))
|
||||
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {});
|
||||
console.debug(authDetails);
|
||||
|
||||
const nonceCount = '00000001';
|
||||
const cnonce = crypto.randomBytes(24).toString('hex');
|
||||
|
||||
if (authDetails.algorithm.toUpperCase() !== 'MD5') {
|
||||
console.warn(`Unsupported Digest algorithm: ${algo}`);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
const HA1 = md5(`${username}:${authDetails['Digest realm']}:${password}`);
|
||||
const HA2 = md5(`${request.method}:${request.url}`);
|
||||
const response = md5(`${HA1}:${authDetails.nonce}:${nonceCount}:${cnonce}:auth:${HA2}`);
|
||||
|
||||
const authorizationHeader =
|
||||
`Digest username="${username}",realm="${authDetails['Digest realm']}",` +
|
||||
`nonce="${authDetails.nonce}",uri="${request.url}",qop="auth",algorithm="${authDetails.algorithm}",` +
|
||||
`response="${response}",nc="${nonceCount}",cnonce="${cnonce}"`;
|
||||
originalRequest.headers['Authorization'] = authorizationHeader;
|
||||
console.debug(`Authorization: ${originalRequest.headers['Authorization']}`);
|
||||
|
||||
delete originalRequest.digestConfig;
|
||||
return axiosInstance(originalRequest);
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = { addDigestInterceptor };
|
@ -26,6 +26,7 @@ const { HttpProxyAgent } = require('http-proxy-agent');
|
||||
const { SocksProxyAgent } = require('socks-proxy-agent');
|
||||
const { makeAxiosInstance } = require('./axios-instance');
|
||||
const { addAwsV4Interceptor, resolveAwsV4Credentials } = require('./awsv4auth-helper');
|
||||
const { addDigestInterceptor } = require('./digestauth-helper');
|
||||
const { shouldUseProxy, PatchedHttpsProxyAgent } = require('../../utils/proxy-util');
|
||||
const { chooseFileToSave, writeBinaryFile } = require('../../utils/filesystem');
|
||||
|
||||
@ -190,6 +191,10 @@ const configureRequest = async (
|
||||
delete request.awsv4config;
|
||||
}
|
||||
|
||||
if (request.digestConfig) {
|
||||
addDigestInterceptor(axiosInstance, request);
|
||||
}
|
||||
|
||||
request.timeout = preferencesUtil.getRequestTimeout();
|
||||
|
||||
return axiosInstance;
|
||||
|
@ -131,6 +131,12 @@ const interpolateVars = (request, envVars = {}, collectionVariables = {}, proces
|
||||
request.awsv4config.profileName = interpolate(request.awsv4config.profileName) || '';
|
||||
}
|
||||
|
||||
// interpolate vars for digest auth
|
||||
if (request.digestConfig) {
|
||||
request.digestConfig.username = interpolate(request.digestConfig.username) || '';
|
||||
request.digestConfig.password = interpolate(request.digestConfig.password) || '';
|
||||
}
|
||||
|
||||
return request;
|
||||
};
|
||||
|
||||
|
@ -29,6 +29,12 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
|
||||
case 'bearer':
|
||||
axiosRequest.headers['authorization'] = `Bearer ${get(collectionAuth, 'bearer.token')}`;
|
||||
break;
|
||||
case 'digest':
|
||||
axiosRequest.digestConfig = {
|
||||
username: get(collectionAuth, 'digest.username'),
|
||||
password: get(collectionAuth, 'digest.password')
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@ -53,6 +59,11 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
|
||||
case 'bearer':
|
||||
axiosRequest.headers['authorization'] = `Bearer ${get(request, 'auth.bearer.token')}`;
|
||||
break;
|
||||
case 'digest':
|
||||
axiosRequest.digestConfig = {
|
||||
username: get(request, 'auth.digest.username'),
|
||||
password: get(request, 'auth.digest.password')
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -23,7 +23,7 @@ const { outdentString } = require('../../v1/src/utils');
|
||||
*/
|
||||
const grammar = ohm.grammar(`Bru {
|
||||
BruFile = (meta | http | query | headers | auths | bodies | varsandassert | script | tests | docs)*
|
||||
auths = authawsv4 | authbasic | authbearer
|
||||
auths = authawsv4 | authbasic | authbearer | authdigest
|
||||
bodies = bodyjson | bodytext | bodyxml | bodysparql | bodygraphql | bodygraphqlvars | bodyforms | body
|
||||
bodyforms = bodyformurlencoded | bodymultipart
|
||||
|
||||
@ -79,6 +79,7 @@ const grammar = ohm.grammar(`Bru {
|
||||
authawsv4 = "auth:awsv4" dictionary
|
||||
authbasic = "auth:basic" dictionary
|
||||
authbearer = "auth:bearer" dictionary
|
||||
authdigest = "auth:digest" dictionary
|
||||
|
||||
body = "body" st* "{" nl* textblock tagend
|
||||
bodyjson = "body:json" st* "{" nl* textblock tagend
|
||||
@ -350,6 +351,21 @@ const sem = grammar.createSemantics().addAttribute('ast', {
|
||||
}
|
||||
};
|
||||
},
|
||||
authdigest(_1, dictionary) {
|
||||
const auth = mapPairListToKeyValPairs(dictionary.ast, false);
|
||||
const usernameKey = _.find(auth, { name: 'username' });
|
||||
const passwordKey = _.find(auth, { name: 'password' });
|
||||
const username = usernameKey ? usernameKey.value : '';
|
||||
const password = passwordKey ? passwordKey.value : '';
|
||||
return {
|
||||
auth: {
|
||||
digest: {
|
||||
username,
|
||||
password
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
bodyformurlencoded(_1, dictionary) {
|
||||
return {
|
||||
body: {
|
||||
|
@ -4,7 +4,7 @@ const { outdentString } = require('../../v1/src/utils');
|
||||
|
||||
const grammar = ohm.grammar(`Bru {
|
||||
BruFile = (meta | query | headers | auth | auths | vars | script | tests | docs)*
|
||||
auths = authawsv4 | authbasic | authbearer
|
||||
auths = authawsv4 | authbasic | authbearer | authdigest
|
||||
|
||||
nl = "\\r"? "\\n"
|
||||
st = " " | "\\t"
|
||||
@ -41,6 +41,7 @@ const grammar = ohm.grammar(`Bru {
|
||||
authawsv4 = "auth:awsv4" dictionary
|
||||
authbasic = "auth:basic" dictionary
|
||||
authbearer = "auth:bearer" dictionary
|
||||
authdigest = "auth:digest" dictionary
|
||||
|
||||
script = scriptreq | scriptres
|
||||
scriptreq = "script:pre-request" st* "{" nl* textblock tagend
|
||||
@ -226,6 +227,21 @@ const sem = grammar.createSemantics().addAttribute('ast', {
|
||||
}
|
||||
};
|
||||
},
|
||||
authdigest(_1, dictionary) {
|
||||
const auth = mapPairListToKeyValPairs(dictionary.ast, false);
|
||||
const usernameKey = _.find(auth, { name: 'username' });
|
||||
const passwordKey = _.find(auth, { name: 'password' });
|
||||
const username = usernameKey ? usernameKey.value : '';
|
||||
const password = passwordKey ? passwordKey.value : '';
|
||||
return {
|
||||
auth: {
|
||||
digest: {
|
||||
username,
|
||||
password
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
varsreq(_1, dictionary) {
|
||||
const vars = mapPairListToKeyValPairs(dictionary.ast);
|
||||
_.each(vars, (v) => {
|
||||
|
@ -114,6 +114,15 @@ ${indentString(`password: ${auth.basic.password}`)}
|
||||
${indentString(`token: ${auth.bearer.token}`)}
|
||||
}
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
if (auth && auth.digest) {
|
||||
bru += `auth:digest {
|
||||
${indentString(`username: ${auth.digest.username}`)}
|
||||
${indentString(`password: ${auth.digest.password}`)}
|
||||
}
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
|
@ -102,6 +102,15 @@ ${indentString(`password: ${auth.basic.password}`)}
|
||||
${indentString(`token: ${auth.bearer.token}`)}
|
||||
}
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
if (auth && auth.digest) {
|
||||
bru += `auth:digest {
|
||||
${indentString(`username: ${auth.digest.username}`)}
|
||||
${indentString(`password: ${auth.digest.password}`)}
|
||||
}
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
|
@ -21,6 +21,11 @@ auth:bearer {
|
||||
token: 123
|
||||
}
|
||||
|
||||
auth:digest {
|
||||
username: john
|
||||
password: secret
|
||||
}
|
||||
|
||||
vars:pre-request {
|
||||
departingDate: 2020-01-01
|
||||
~returningDate: 2020-01-02
|
||||
|
@ -27,6 +27,10 @@
|
||||
},
|
||||
"bearer": {
|
||||
"token": "123"
|
||||
},
|
||||
"digest": {
|
||||
"username": "john",
|
||||
"password": "secret"
|
||||
}
|
||||
},
|
||||
"vars": {
|
||||
|
@ -40,6 +40,11 @@ auth:bearer {
|
||||
token: 123
|
||||
}
|
||||
|
||||
auth:digest {
|
||||
username: john
|
||||
password: secret
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"hello": "world"
|
||||
|
@ -59,6 +59,10 @@
|
||||
},
|
||||
"bearer": {
|
||||
"token": "123"
|
||||
},
|
||||
"digest": {
|
||||
"username": "john",
|
||||
"password": "secret"
|
||||
}
|
||||
},
|
||||
"body": {
|
||||
|
@ -94,11 +94,19 @@ const authBearerSchema = Yup.object({
|
||||
.noUnknown(true)
|
||||
.strict();
|
||||
|
||||
const authDigestSchema = Yup.object({
|
||||
username: Yup.string().nullable(),
|
||||
password: Yup.string().nullable()
|
||||
})
|
||||
.noUnknown(true)
|
||||
.strict();
|
||||
|
||||
const authSchema = Yup.object({
|
||||
mode: Yup.string().oneOf(['none', 'awsv4', 'basic', 'bearer']).required('mode is required'),
|
||||
mode: Yup.string().oneOf(['none', 'awsv4', 'basic', 'bearer', 'digest']).required('mode is required'),
|
||||
awsv4: authAwsV4Schema.nullable(),
|
||||
basic: authBasicSchema.nullable(),
|
||||
bearer: authBearerSchema.nullable()
|
||||
bearer: authBearerSchema.nullable(),
|
||||
digest: authDigestSchema.nullable()
|
||||
})
|
||||
.noUnknown(true)
|
||||
.strict();
|
||||
|
Loading…
Reference in New Issue
Block a user