I'm trying to verify a HMAC signature received from a WebHook. The details of the WebHook are https://cloudconvert.com/api/v2/webhooks#webhooks-events
This says that the HMAC is generated using hash_hmac (PHP) and is a SHA256 hash of the body - which is JSON. An example received is:
c4faebbfb4e81db293801604d0565cf9701d9e896cae588d73ddfef3671e97d7
This looks like lowercase hexits.
I'm trying to use Cloudflare Workers to process the request, however I can't verify the hash. My code is below:
const encoder = new TextEncoder()
addEventListener('fetch', event => {
event.respondWith(handleRequest(event.request))
})
async function handleRequest(request) {
const contentType = request.headers.get('content-type') || ''
const signature = request.headers.get('CloudConvert-Signature')
let data
await S.put('HEADER', signature)
if (contentType.includes('application/json')) {
data = await request.json()
await S.put('EVENT', data.event)
await S.put('TAG', data.job.tag)
await S.put('JSON', JSON.stringify(data))
}
const key2 = await crypto.subtle.importKey(
'raw',
encoder.encode(CCSigningKey2),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
)
const signed2 = await crypto.subtle.sign(
'HMAC',
key2,
encoder.encode(JSON.stringify(data))
)
await S.put('V22', btoa(String.fromCharCode(...new Uint8Array(signed2))))
return new Response(null, {
status: 204,
headers: {
'Cache-Control': 'no-cache'
}
})
}
This will generate a hash of:
e52613e6ecebdf98bb085f04ca1f91bf9a5cf1dc085f89dcaa3e5fbf5ebf1b06
I've tried use the crypto.subtle.verify method, but that didn't work.
Can anyone see any issues with the code? Or have done this successfully using Cloudflare Workers?
Mark
I finally got this working using the verify method (I had previously tried the verify method, but it didn't work). The main problem seems to the use of request.json() wrapped in JSON.stringify. Changing this to request.text() resolved the issue. I can then use JSON.parse to access the data after verifying the signature. The code is as follows:
const encoder = new TextEncoder()
addEventListener('fetch', event => {
event.respondWith(handleRequest(event.request))
})
async function handleRequest(request) {
const signature = request.headers.get('CloudConvert-Signature')
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(CCSigningKey2),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
)
const data = await request.text()
const verified = await crypto.subtle.verify(
'HMAC',
key,
hexStringToArrayBuffer(signature),
encoder.encode(data)
)
if (!verified) {
return new Response('Verification failed', {
status: 401,
headers: {
'Cache-Control': 'no-cache'
}
})
}
return new Response(null, {
status: 204,
headers: {
'Cache-Control': 'no-cache'
}
})
}
function hexStringToArrayBuffer(hexString) {
hexString = hexString.replace(/^0x/, '')
if (hexString.length % 2 != 0) {
return
}
if (hexString.match(/[G-Z\s]/i)) {
return
}
return new Uint8Array(
hexString.match(/[\dA-F]{2}/gi).map(function(s) {
return parseInt(s, 16)
})
).buffer
}
Related
I'm trying to get my head around the Nuxt /server API and can't seem to figure out how to send a POST request with form-data (ie files) to Nuxt server to forward on to an external service:
In my pages.vue file I have this method:
async function onSubmit() {
const formData = new FormData();
for (let file of form.files) {
await formData.append("image", file);
}
await $fetch("/api/send", {
method: "POST",
body: formData
});
}
and then in /server/api/send.js I have:
export default defineEventHandler(async (event) => {
const { method } = event.node.req;
// I THINK THE ISSUE IS HERE
const body =
method !== "GET" && method !== "HEAD"
? await readMultipartFormData(event)
: undefined;
const response = await $fetch.raw(https://*******, {
method,
baseURL: *********,
headers: {
},
body: body
});
return response._data;
}
I'm effectively creating a passthrough API using Nuxt so that the external endpoint isn't exposed to the end user. Just can't figure out how to access the formData in the correct format to pass through on the server side. I don't think I am supposed to use readMultipartFormData() because that seems to be parsing the data somehow whereas I just want to pass the formData straight through to the external API. Any tips?
I've tried using both readMultipartFormData() and readBody() and neither seem to work. I don't actually need to read the body but rather get it and pass it through without any formatting...
If you want to pass the data with formdata to the endpoint try this library:
https://www.npmjs.com/package/object-to-formdata
code:
import { serialize } from 'object-to-formdata';
const formData = serialize(body);
const response = await $fetch.raw(https://*******, {
method,
baseURL: *********,
headers: {
},
body: formData
});
I managed to make it work with ugly solution, first you have to update nuxt to version 3.2.0 min then here my front side
let jobApplicationDTO = {
firstName: values.firstName,
lastName: values.lastName,
email: values.email,
phoneNumber: values.phoneNumber,
company: values.company,
shortDescription: values.shortDescription
};
const formData = new FormData();
formData.append("application", new Blob([JSON.stringify(jobApplicationDTO)], {type: "application/json"}));
formData.append("file", values.file) ;
//formData.append("file", values.file );
await useFetch("/api/application", {
method: "POST",
body: formData,
onResponse({request, response, options}) {
// Process the response data
if (response.status === 200) {
errorMessage.value = "";
successMessage.value = "Your application wa sent successfully, you will be contacted soon !";
}
},
onResponseError({request, response, options}) {
console.debug(response);
if (response.status === 400) {
successMessage.value = "";
errorMessage.value = "There may be an issue with our server. Please try again later, or send an email to support#mantiq.com";
} else {
successMessage.value = "";
errorMessage.value = "Sorry we couldn’t send the message, there may be an issue with our server. Please try again later, or send an email to support#mantiq.com";
}
},
});
}
and server side
import {FormData} from "node-fetch-native";
export default defineEventHandler(async (event) => {
const {BACKEND_REST_API, ENQUIRY_TOKEN} = useRuntimeConfig();
//retrieve frontend post formData
const form = await readMultipartFormData(event);
const applicationUrl = BACKEND_REST_API + '/job/apply'
console.log("url used for enquiry rest call :" + applicationUrl);
console.log("Job application token :" + ENQUIRY_TOKEN);
const formData = new FormData();
console.log(form);
if (form) {
formData.append(form[0].name, new Blob([JSON.stringify(JSON.parse(form[0].data))], {type: form[0].type}));
formData.append(form[1].name, new Blob([form[1].data], {type: form[1].type}), form[1].filename);
}
console.log(formData.values);
return await $fetch(applicationUrl, {
method: "POST",
body: formData,
headers: {
Authorization: ENQUIRY_TOKEN,
},
});
})
What is funny is on frontend you have to create a formData , then to get content and to recreate a formData from your previous formData converted in MultiFormPart[], i created a ticket on nuxt to see how to do it properly
I am trying to upload a lot of files from S3 to IPFS via Pinata. I haven't found in Pinata documentation something like that.
This is my solution, using the form-data library. I haven't tested it yet (I will do it soon, I need to code some things).
Is it a correct approach? anyone who has done something similar?
async uploadImagesFolder(
items: ItemDocument[],
bucket?: string,
path?: string,
) {
try {
const form = new FormData();
for (const item of items) {
const file = getObjectStream(item.tokenURI, bucket, path);
form.append('file', file, {
filename: item.tokenURI,
});
}
console.log(`Uploading files to IPFS`);
const pinataOptions: PinataOptions = {
cidVersion: 1,
};
const result = await pinata.pinFileToIPFS(form, {
pinataOptions,
});
console.log(`Piñata Response:`, JSON.stringify(result, null, 2));
return result.IpfsHash;
} catch (e) {
console.error(e);
}
}
I had the same problem
So, I have found this: https://medium.com/pinata/stream-files-from-aws-s3-to-ipfs-a0e23ffb7ae5
But in the article If am not wrong, is used a different version to the JavaScript AWS SDK v3 (nowadays the most recent: https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/index.html).
This is for the Client side with TypeScript:
If you have this version, for me works this code snippet:
export const getStreamObjectInAwsS3 = async (data: YourParamsType) => {
try {
const BUCKET = data.bucketTarget
const KEY = data.key
const client = new S3Client({
region: 'your-region',
credentials: {
accessKeyId: 'your-access-key',
secretAccessKey: 'secret-key'
}
})
const resource = await client.send(new GetObjectCommand({
Bucket: BUCKET,
Key: KEY
}))
const response = resource.Body
if (response) {
return new Response(await response.transformToByteArray()).blob()
}
return null
} catch (error) {
return null
}
}
With the previous code, you can get the Blob Object for pass it to the File object with this method and get the URL resource using the API:
export const uploadFileToIPFS = async(file: Response) => {
const url = `https://api.pinata.cloud/pinning/pinFileToIPFS`
const data = new FormData()
data.append('file', file)
try {
const response = await axios.post(url, data, {
maxBodyLength: Infinity,
headers: {
pinata_api_key: 'your-api',
pinata_secret_api_key: 'your-secret'
},
data: data
})
return {
success: true,
pinataURL: `https://gateway.pinata.cloud/ipfs/${ response.data.IpfsHash }`
}
} catch (error) {
console.log(error)
return null
}
}
I have found this solution from this nice article and you can explore other implementations (including the Node.js side)
I know some questions about the subject has been opened here and there, but my issue is different :
all the other ones appear in dev mode, in my case it's in production,
a very big percentage of requests pass, a few of them is TypeError: Network request failed - but sometimes for critical requests
it's random, not always the same request. Sometimes it passes, sometimes not.
it appears to three on my projects, one is on AWS the other one on Clever-Cloud, both are projects between 1000 and 5000 users, servers are quite too big for what they do - I think I removed the risk of a server fault. Even if... I can reproduce locally when I don't start the api locally. So it's like the api is not responding, but as I said, I don't think so.
I have no clue where to dig anymore...
I can give you my API.js service file, maybe you'll find what's wrong ?
import URI from 'urijs';
import { Platform } from 'react-native';
import NetInfo from '#react-native-community/netinfo';
import { getUserToken, wipeData } from '../utils/data';
import { SCHEME, MW_API_HOST } from '../config';
import deviceInfoModule from 'react-native-device-info';
import { capture } from '../utils/sentry';
const unauthorisedHandler = (navigation) => {
wipeData();
navigation.reset({ index: 0, routes: [{ name: 'Auth' }] });
};
const checkNetwork = async (test = false) => {
const isConnected = await NetInfo.fetch().then((state) => state.isConnected);
if (!isConnected || test) {
await new Promise((res) => setTimeout(res, 1500));
return false;
}
return true;
};
class ApiService {
host = MW_API_HOST;
scheme = SCHEME;
getUrl = (path, query) => {
return new URI().host(this.host).scheme(this.scheme).path(path).setSearch(query).toString();
};
execute = async ({ method = 'GET', path = '', query = {}, headers = {}, body = null }) => {
try {
const config = {
method,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
appversion: deviceInfoModule.getBuildNumber(),
appdevice: Platform.OS,
currentroute: this.navigation?.getCurrentRoute?.()?.name,
...headers,
},
body: body ? JSON.stringify(body) : null,
};
const url = this.getUrl(path, query);
console.log('url: ', url);
const canFetch = await checkNetwork();
if (!canFetch) return;
let response;
// To try to avoid mysterious `TypeError: Network request failed` error
// that throws an error directly
// we try catch and try one more time.
try {
response = await fetch(url, config);
} catch (e) {
if (e?.toString().includes('Network request failed')) {
// try again
await new Promise((res) => setTimeout(res, 250));
console.log('try again because Network request failed');
response = await fetch(url, config);
} else {
throw e;
}
}
if (!response.ok) {
if (response.status === 401) {
const token = await getUserToken();
if (token) unauthorisedHandler(API.navigation);
return response;
}
}
if (response.json) return await response.json();
return response;
} catch (e) {
capture(e, { extra: { method, path, query, headers, body } });
return { ok: false, error: "Sorry, an error occured, technical team has been warned." };
}
};
executeWithToken = async ({ method = 'GET', path = '', query = {}, headers = {}, body = null }) => {
const token = await getUserToken();
if (token) headers.Authorization = token;
return this.execute({ method, path, query, headers, body });
};
get = async (args) => this.executeWithToken({ method: 'GET', ...args });
post = async (args) => this.executeWithToken({ method: 'POST', ...args });
put = async (args) => this.executeWithToken({ method: 'PUT', ...args });
delete = async (args) => this.executeWithToken({ method: 'DELETE', ...args });
}
const API = new ApiService();
export default API;
Talking with experts here and there, it seems that it's normal : internet network is not 100% reliable, so sometimes, request fail, for a reason that we can't anticipate (tunnel, whatever).
I ended up using fetch-retry and I still have a few of those, but much less !
So we're creating a React-Native app using Apollo and GraphQL. I'm using JWT based authentication(when user logs in both an activeToken and refreshToken is created), and want to implement a flow where the token gets refreshed automatically when the server notices it's been expired.
The Apollo Docs for Apollo-Link-Error provides a good starting point to catch the error from the ApolloClient:
onError(({ graphQLErrors, networkError, operation, forward }) => {
if (graphQLErrors) {
for (let err of graphQLErrors) {
switch (err.extensions.code) {
case 'UNAUTHENTICATED':
// error code is set to UNAUTHENTICATED
// when AuthenticationError thrown in resolver
// modify the operation context with a new token
const oldHeaders = operation.getContext().headers;
operation.setContext({
headers: {
...oldHeaders,
authorization: getNewToken(),
},
});
// retry the request, returning the new observable
return forward(operation);
}
}
}
})
However, I am really struggling to figure out how to implement getNewToken().
My GraphQL endpoint has the resolver to create new tokens, but I can't call it from Apollo-Link-Error right?
So how do you refresh the token if the Token is created in the GraphQL endpoint that your Apollo Client will connect to?
The example given in the the Apollo Error Link documentation is a good starting point but assumes that the getNewToken() operation is synchronous.
In your case, you have to hit your GraphQL endpoint to retrieve a new access token. This is an asynchronous operation and you have to use the fromPromise utility function from the apollo-link package to transform your Promise to an Observable.
import React from "react";
import { AppRegistry } from 'react-native';
import { onError } from "apollo-link-error";
import { fromPromise, ApolloLink } from "apollo-link";
import { ApolloClient } from "apollo-client";
let apolloClient;
const getNewToken = () => {
return apolloClient.query({ query: GET_TOKEN_QUERY }).then((response) => {
// extract your accessToken from your response data and return it
const { accessToken } = response.data;
return accessToken;
});
};
const errorLink = onError(
({ graphQLErrors, networkError, operation, forward }) => {
if (graphQLErrors) {
for (let err of graphQLErrors) {
switch (err.extensions.code) {
case "UNAUTHENTICATED":
return fromPromise(
getNewToken().catch((error) => {
// Handle token refresh errors e.g clear stored tokens, redirect to login
return;
})
)
.filter((value) => Boolean(value))
.flatMap((accessToken) => {
const oldHeaders = operation.getContext().headers;
// modify the operation context with a new token
operation.setContext({
headers: {
...oldHeaders,
authorization: `Bearer ${accessToken}`,
},
});
// retry the request, returning the new observable
return forward(operation);
});
}
}
}
}
);
apolloClient = new ApolloClient({
link: ApolloLink.from([errorLink, authLink, httpLink]),
});
const App = () => (
<ApolloProvider client={apolloClient}>
<MyRootComponent />
</ApolloProvider>
);
AppRegistry.registerComponent('MyApplication', () => App);
You can stop at the above implementation which worked correctly until two or more requests failed concurrently. So, to handle concurrent requests failure on token expiration, have a look at this post.
Update - Jan 2022
you can see basic React JWT Authentication Setup from: https://github.com/bilguun-zorigt/React-GraphQL-JWT-Authentication-Example
I've also added the safety points to consider when setting up authentication on both the frontend and backend on the Readme section of the repository. (XSS attack, csrf attack etc...)
Original answer - Dec 2021
My solution:
Works with concurrent requests (by using single promise for all requests)
Doesn't wait for error to happen
Used second client for refresh mutation
import { setContext } from '#apollo/client/link/context';
async function getRefreshedAccessTokenPromise() {
try {
const { data } = await apolloClientAuth.mutate({ mutation: REFRESH })
// maybe dispatch result to redux or something
return data.refreshToken.token
} catch (error) {
// logout, show alert or something
return error
}
}
let pendingAccessTokenPromise = null
export function getAccessTokenPromise() {
const authTokenState = reduxStoreMain.getState().authToken
const currentNumericDate = Math.round(Date.now() / 1000)
if (authTokenState && authTokenState.token && authTokenState.payload &&
currentNumericDate + 1 * 60 <= authTokenState.payload.exp) {
//if (currentNumericDate + 3 * 60 >= authTokenState.payload.exp) getRefreshedAccessTokenPromise()
return new Promise(resolve => resolve(authTokenState.token))
}
if (!pendingAccessTokenPromise) pendingAccessTokenPromise = getRefreshedAccessTokenPromise().finally(() => pendingAccessTokenPromise = null)
return pendingAccessTokenPromise
}
export const linkTokenHeader = setContext(async (_, { headers }) => {
const accessToken = await getAccessTokenPromise()
return {
headers: {
...headers,
Authorization: accessToken ? `JWT ${accessToken}` : '',
}
}
})
export const apolloClientMain = new ApolloClient({
link: ApolloLink.from([
linkError,
linkTokenHeader,
linkMain
]),
cache: inMemoryCache
});
If you are using JWT, you should be able to detect when your JWT token is about to expire or if it is already expired.
Therefore, you do not need to make a request that will always fail with 401 unauthorized.
You can simplify the implementation this way:
const REFRESH_TOKEN_LEGROOM = 5 * 60
export function getTokenState(token?: string | null) {
if (!token) {
return { valid: false, needRefresh: true }
}
const decoded = decode(token)
if (!decoded) {
return { valid: false, needRefresh: true }
} else if (decoded.exp && (timestamp() + REFRESH_TOKEN_LEGROOM) > decoded.exp) {
return { valid: true, needRefresh: true }
} else {
return { valid: true, needRefresh: false }
}
}
export let apolloClient : ApolloClient<NormalizedCacheObject>
const refreshAuthToken = async () => {
return apolloClient.mutate({
mutation: gql```
query refreshAuthToken {
refreshAuthToken {
value
}```,
}).then((res) => {
const newAccessToken = res.data?.refreshAuthToken?.value
localStorage.setString('accessToken', newAccessToken);
return newAccessToken
})
}
const apolloHttpLink = createHttpLink({
uri: Config.graphqlUrl
})
const apolloAuthLink = setContext(async (request, { headers }) => {
// set token as refreshToken for refreshing token request
if (request.operationName === 'refreshAuthToken') {
let refreshToken = localStorage.getString("refreshToken")
if (refreshToken) {
return {
headers: {
...headers,
authorization: `Bearer ${refreshToken}`,
}
}
} else {
return { headers }
}
}
let token = localStorage.getString("accessToken")
const tokenState = getTokenState(token)
if (token && tokenState.needRefresh) {
const refreshPromise = refreshAuthToken()
if (tokenState.valid === false) {
token = await refreshPromise
}
}
if (token) {
return {
headers: {
...headers,
authorization: `Bearer ${token}`,
}
}
} else {
return { headers }
}
})
apolloClient = new ApolloClient({
link: apolloAuthLink.concat(apolloHttpLink),
cache: new InMemoryCache()
})
The advantage of this implementation:
If the access token is about to expire (REFRESH_TOKEN_LEGROOM), it will request a refresh token without stopping the current query. Which should be invisible to your user
If the access token is already expired, it will refresh the token and wait for the response to update it. Much faster than waiting for the error back
The disadvantage:
If you make many requests at once, it may request several times a refresh. You can easily protect against it by waiting a global promise for example. But you will have to implement a proper race condition check if you want to guaranty only one refresh.
after checking this topic and some others very good on internet, my code worked with the following solution
ApolloClient,
NormalizedCacheObject,
gql,
createHttpLink,
InMemoryCache,
} from '#apollo/client';
import { setContext } from '#apollo/client/link/context';
import jwt_decode, { JwtPayload } from 'jwt-decode';
import {
getStorageData,
setStorageData,
STORAGE_CONTANTS,
} from '../utils/local';
export function isRefreshNeeded(token?: string | null) {
if (!token) {
return { valid: false, needRefresh: true };
}
const decoded = jwt_decode<JwtPayload>(token);
if (!decoded) {
return { valid: false, needRefresh: true };
}
if (decoded.exp && Date.now() >= decoded.exp * 1000) {
return { valid: false, needRefresh: true };
}
return { valid: true, needRefresh: false };
}
export let client: ApolloClient<NormalizedCacheObject>;
const refreshAuthToken = async () => {
const refreshToken = getStorageData(STORAGE_CONTANTS.REFRESHTOKEN);
const newToken = await client
.mutate({
mutation: gql`
mutation RefreshToken($refreshAccessTokenRefreshToken: String!) {
refreshAccessToken(refreshToken: $refreshAccessTokenRefreshToken) {
accessToken
status
}
}
`,
variables: { refreshAccessTokenRefreshToken: refreshToken },
})
.then(res => {
const newAccessToken = res.data?.refreshAccessToken?.accessToken;
setStorageData(STORAGE_CONTANTS.AUTHTOKEN, newAccessToken, true);
return newAccessToken;
});
return newToken;
};
const apolloHttpLink = createHttpLink({
uri: process.env.REACT_APP_API_URL,
});
const apolloAuthLink = setContext(async (request, { headers }) => {
if (request.operationName !== 'RefreshToken') {
let token = getStorageData(STORAGE_CONTANTS.AUTHTOKEN);
const shouldRefresh = isRefreshNeeded(token);
if (token && shouldRefresh.needRefresh) {
const refreshPromise = await refreshAuthToken();
if (shouldRefresh.valid === false) {
token = await refreshPromise;
}
}
if (token) {
return {
headers: {
...headers,
authorization: `${token}`,
},
};
}
return { headers };
}
return { headers };
});
client = new ApolloClient({
link: apolloAuthLink.concat(apolloHttpLink),
cache: new InMemoryCache(),
});
A much simpler solution is using RetryLink. retryIf supports async operations so one could do something like this:
class GraphQLClient {
constructor() {
const httpLink = new HttpLink({ uri: '<graphql-endpoint>', fetch: fetch })
const authLink = setContext((_, { headers }) => this._getAuthHeaders(headers))
const retryLink = new RetryLink({
delay: { initial: 300, max: Infinity, jitter: false },
attempts: {
max: 3,
retryIf: (error, operation) => this._handleRetry(error, operation)
}})
this.client = new ApolloClient({
link: ApolloLink.from([ authLink, retryLink, httpLink ]),
cache: new InMemoryCache()
})
}
async _handleRetry(error, operation) {
let requiresRetry = false
if (error.statusCode === 401) {
requiresRetry = true
if (!this.refreshingToken) {
this.refreshingToken = true
await this.requestNewAccessToken()
operation.setContext(({ headers = {} }) => this._getAuthHeaders(headers))
this.refreshingToken = false
}
}
return requiresRetry
}
async requestNewAccessToken() {
// get new access token
}
_getAuthHeaders(headers) {
// return headers
}
}
I copy-pasted example from official documentation in hope that I will see some different error (invalid URL or invalid auth key or something similar) However I get some webpack/sandbox error:
const fetch = require('isomorphic-fetch')
const Base64 = require('Base64')
const FormData =require('form-data')
const apiKey = '__MAILGUN_API_KEY__'
const url = '__MAILGUN_URL__'
export default event => {
const form = new FormData()
form.append('from', 'Nilan <nilan#graph.cool>')
form.append('to', 'Nikolas <nikolas#graph.cool>')
form.append('subject', 'Test')
form.append('text', 'Hi')
return fetch(url, {
headers: {
'Authorization': `Basic ${Base64.btoa(apiKey)}`
},
method: 'POST',
body: form
})
}
Even simple API requests fail:
require('isomorphic-fetch')
module.exports = function (event) {
const url = 'https://jsonplaceholder.typicode.com/posts'
return fetch(url)
}
The code above also returns:
TypeError: Converting circular structure to JSON
at Object.stringify (native)
at /data/sandbox/lib/sandbox.js:532:48
at /data/io/8e0059b3-daeb-4989-972f-e0d88e27d15e/webtask.js:46:33
at process._tickDomainCallback (node.js:481:9)
How do I successfully call API from custom graphcool subscription/resolver?
This is the simplest working example:
require('isomorphic-fetch')
module.exports = function (event) {
const url = 'https://jsonplaceholder.typicode.com/posts'
return fetch(url)
.then(res => res.json())
.then(data => {
console.log(data)
return {
data: {
sum: 3
}
}
})
}