Skip to content

Commit

Permalink
Merge pull request #259 from futurice/promise-chains-to-async
Browse files Browse the repository at this point in the history
Convert promise chains to async functions
  • Loading branch information
jareware authored Apr 23, 2020
2 parents 464c339 + a34c55e commit 87fcf74
Show file tree
Hide file tree
Showing 8 changed files with 276 additions and 314 deletions.
8 changes: 8 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
"aws-sdk": "^2.642.0",
"check-node-version": "^4.0.2",
"concurrently": "^5.1.0",
"nan": "2.14.1",
"prettier": "^1.19.1",
"ts-node": "^8.8.1",
"typescript": "^3.8.3"
Expand Down
358 changes: 156 additions & 202 deletions src/backend/abuseDetection.test.ts

Large diffs are not rendered by default.

30 changes: 15 additions & 15 deletions src/backend/abuseDetection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,8 @@ export function createDynamoDbClient(
// Increments the integer value at given key.
// If the key doesn't exist, it's created automatically as having value 0, then incremented normally.
// Returns the integer value (after being incremented).
incrementKey(key: string): Promise<number> {
return ddb
async incrementKey(key: string): Promise<number> {
const res = await ddb
.updateItem({
TableName: tableName,
Key: { ADKey: { S: key } },
Expand All @@ -79,15 +79,15 @@ export function createDynamoDbClient(
},
ReturnValues: 'UPDATED_NEW',
})
.promise()
.then(res => res?.Attributes?.ADVal)
.then(unwrapNumber);
.promise();

return unwrapNumber(res?.Attributes?.ADVal);
},

// Returns the integer values at given keys.
// If some keys don't contain values, they're treated as 0's.
getValues(keys: string[]): Promise<number[]> {
return ddb
async getValues(keys: string[]): Promise<number[]> {
const res = await ddb
.batchGetItem({
RequestItems: {
[tableName]: {
Expand All @@ -96,14 +96,14 @@ export function createDynamoDbClient(
},
},
})
.promise()
.then(res =>
(res?.Responses?.[tableName] || []).reduce(
(memo, next) => ({ ...memo, [next.ADKey.S || '']: unwrapNumber(next.ADVal) }),
{} as { [key: string]: number },
),
)
.then(res => keys.map(key => res[key] || 0));
.promise();

const valueMap = (res?.Responses?.[tableName] || []).reduce(
(memo, next) => ({ ...memo, [next.ADKey.S || '']: unwrapNumber(next.ADVal) }),
{} as { [key: string]: number },
);

return keys.map(key => valueMap[key] || 0);
},
};
}
Expand Down
41 changes: 20 additions & 21 deletions src/backend/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,8 @@ const persistedResponseSample: BackendResponseModelT = {
};

describe('prepareResponseForStorage()', () => {
it('works for the first request', () => {
return prepareResponseForStorage(
it('works for the first request', async () => {
const r = await prepareResponseForStorage(
incomingResponseSample,
'FI',
createMockDynamoDbClient(),
Expand All @@ -70,11 +70,12 @@ describe('prepareResponseForStorage()', () => {
Promise.resolve('fake-secret-pepper'),
() => cannedUuid,
() => 1585649303678, // i.e. "2020-03-31T10:08:23.678Z"
).then(r => expect(r).toEqual(persistedResponseSample));
);
expect(r).toEqual(persistedResponseSample);
});

it('works for a second request', () => {
return prepareResponseForStorage(
it('works for a second request', async () => {
const r = await prepareResponseForStorage(
incomingResponseSample,
'FI',
{
Expand All @@ -91,16 +92,15 @@ describe('prepareResponseForStorage()', () => {
Promise.resolve('fake-secret-pepper'),
() => cannedUuid,
() => 1585649303678, // i.e. "2020-03-31T10:08:23.678Z"
).then(r =>
expect(r).toEqual({
...persistedResponseSample,
abuse_score: { ...persistedResponseSample.abuse_score, source_ip: 123 },
}),
);
expect(r).toEqual({
...persistedResponseSample,
abuse_score: { ...persistedResponseSample.abuse_score, source_ip: 123 },
});
});

it('handles errors', () => {
return prepareResponseForStorage(
it('handles errors', async () => {
const r = await prepareResponseForStorage(
incomingResponseSample,
'FI',
{
Expand All @@ -117,16 +117,15 @@ describe('prepareResponseForStorage()', () => {
Promise.resolve('fake-secret-pepper'),
() => cannedUuid,
() => 1585649303678, // i.e. "2020-03-31T10:08:23.678Z"
).then(r =>
expect(r).toEqual({
...persistedResponseSample,
abuse_score: {
forwarded_for: -2,
source_ip: -2,
user_agent: -2,
},
}),
);
expect(r).toEqual({
...persistedResponseSample,
abuse_score: {
forwarded_for: -2,
source_ip: -2,
user_agent: -2,
},
});
});
});

Expand Down
99 changes: 48 additions & 51 deletions src/backend/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,38 +35,33 @@ const athenaExpress = new AthenaExpress({ aws: AWS, s3: `s3://${athenaResultsBuc
let cachedSecretPepper: undefined | Promise<string>;

// Saves the given response into our storage bucket
export function storeResponse(
export async function storeResponse(
response: FrontendResponseModelT,
countryCode: string,
dynamoDb: DynamoDBClient,
fingerprint: AbuseFingerprint,
) {
return Promise.resolve()
.then(() =>
prepareResponseForStorage(
response,
countryCode,
dynamoDb,
fingerprint,
(cachedSecretPepper = cachedSecretPepper || getSecret('secret-pepper')),
),
)
.then(r => {
console.log('About to store response', r);
return s3
.putObject({
Bucket: storageBucket,
Key: getStorageKey(r),
Body: JSON.stringify(r),
ACL: 'private',
})
.promise();
const r = await prepareResponseForStorage(
response,
countryCode,
dynamoDb,
fingerprint,
(cachedSecretPepper = cachedSecretPepper || getSecret('secret-pepper')),
);

console.log('About to store response', r);
await s3
.putObject({
Bucket: storageBucket,
Key: getStorageKey(r),
Body: JSON.stringify(r),
ACL: 'private',
})
.then(() => {}); // don't promise any value, just the success of the operation
.promise();
}

// Takes a response from the frontend, scrubs it clean, and adds fields required for storing it
export function prepareResponseForStorage(
export async function prepareResponseForStorage(
response: FrontendResponseModelT,
countryCode: string,
dynamoDb: DynamoDBClient,
Expand All @@ -76,34 +71,36 @@ export function prepareResponseForStorage(
uuid: () => string = uuidV4,
timestamp = Date.now,
): Promise<BackendResponseModelT> {
return Promise.resolve(secretPepper).then(secretPepper => {
const { readPromise, writePromise } = performAbuseDetection(dynamoDb, fingerprint, val => hash(val, secretPepper));
writePromise // we don't really care about the write operation here - it can finish on its own (we only need to handle its possible failure; if it keeps failing we want to know)
.catch(err => console.log(`Error: Couldn't write abuse score for response (caused by\n${err}\n)`));
return readPromise // we only care about the read operation
.catch(
(err): AbuseScore => {
console.log(`Error: Couldn't read abuse score for response; marking with error code (caused by\n${err}\n)`);
return ABUSE_SCORE_ERROR;
},
)
.then(abuse_score => {
const meta = {
response_id: uuid(),
participant_id: hash(hash(response.participant_id, knownPepper), secretPepper), // to preserve privacy, hash the participant_id before storing it, so after opening up the dataset, malicious actors can't submit more responses that pretend to belong to a previous participant
timestamp: new Date(timestamp()) // for security, don't trust browser clock, as it may be wrong or fraudulent
.toISOString()
.replace(/:..\..*/, ':00.000Z'), // to preserve privacy, intentionally reduce precision of the timestamp
app_version: APP_VERSION, // document the app version that was used to process this response
country_code: countryCode,
postal_code: mapPostalCode(response).postal_code, // to protect the privacy of participants from very small postal code areas, they are merged into larger ones, based on known population data
duration: response.duration === null ? null : parseInt(response.duration),
abuse_score,
};
const model: BackendResponseModelT = { ...meta, ...response, ...meta }; // the double "...meta" is just for vanity: we want the meta-fields to appear first in the JSON representation
return assertIs(BackendResponseModel)(model); // ensure we still pass runtime validations as well
});
});
const secretPepperValue = await secretPepper;
const { readPromise, writePromise } = performAbuseDetection(dynamoDb, fingerprint, val =>
hash(val, secretPepperValue),
);

writePromise // we don't really care about the write operation here - it can finish on its own (we only need to handle its possible failure; if it keeps failing we want to know)
.catch(err => console.log(`Error: Couldn't write abuse score for response (caused by\n${err}\n)`));

const abuse_score = await readPromise // we only care about the read operation
.catch(
(err): AbuseScore => {
console.log(`Error: Couldn't read abuse score for response; marking with error code (caused by\n${err}\n)`);
return ABUSE_SCORE_ERROR;
},
);

const meta = {
response_id: uuid(),
participant_id: hash(hash(response.participant_id, knownPepper), secretPepperValue), // to preserve privacy, hash the participant_id before storing it, so after opening up the dataset, malicious actors can't submit more responses that pretend to belong to a previous participant
timestamp: new Date(timestamp()) // for security, don't trust browser clock, as it may be wrong or fraudulent
.toISOString()
.replace(/:..\..*/, ':00.000Z'), // to preserve privacy, intentionally reduce precision of the timestamp
app_version: APP_VERSION, // document the app version that was used to process this response
country_code: countryCode,
postal_code: mapPostalCode(response).postal_code, // to protect the privacy of participants from very small postal code areas, they are merged into larger ones, based on known population data
duration: response.duration === null ? null : parseInt(response.duration),
abuse_score,
};
const model: BackendResponseModelT = { ...meta, ...response, ...meta }; // the double "...meta" is just for vanity: we want the meta-fields to appear first in the JSON representation
return assertIs(BackendResponseModel)(model); // ensure we still pass runtime validations as well
}

// Produces the key under which this response should be stored in S3
Expand Down
21 changes: 12 additions & 9 deletions src/backend/secrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,18 @@ import * as AWS from 'aws-sdk';
const ssm = new AWS.SSM(); // note: for local development, you may need to: AWS.config.update({ region: 'eu-west-1' });

// Fetches a correctly prefixed secret value from AWS Systems Manager Parameter Store (SSM)
export function getSecret(name: 'secret-pepper') {
export async function getSecret(name: 'secret-pepper') {
const fullName = process.env.SSM_SECRETS_PREFIX + name;
console.log(`Getting secret "${fullName}"`);
return ssm
.getParameters({
Names: [fullName],
WithDecryption: true,
})
.promise()
.then(data => (data.Parameters || [])[0].Value || '')
.catch(err => Promise.reject(new Error(`Couldn't get secret "${fullName}" (caused by\n${err}\n)`)));
try {
const data = await ssm
.getParameters({
Names: [fullName],
WithDecryption: true,
})
.promise();
return (data.Parameters || [])[0].Value || '';
} catch (err) {
throw new Error(`Couldn't get secret "${fullName}" (caused by\n${err}\n)`);
}
}
32 changes: 16 additions & 16 deletions src/index-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,26 +14,26 @@ const dynamoDb = createDynamoDbClient(process.env.ABUSE_DETECTION_TABLE || '');

console.log(`Backend ${APP_VERSION} started`);

export const apiEntrypoint: APIGatewayProxyHandler = (event, context) => {
export const apiEntrypoint: APIGatewayProxyHandler = async (event, context) => {
console.log(`Incoming request: ${event.httpMethod} ${event.path}`); // to preserve privacy, don't log any headers, etc
if (event.httpMethod === 'OPTIONS') {
return Promise.resolve().then(() => response(200, undefined));
return response(200, undefined);
} else if (event.httpMethod === 'POST') {
const countryCode = event.headers['CloudFront-Viewer-Country'] || '';
return Promise.resolve()
.then(() => JSON.parse(event.body || '') as unknown)
.then(assertIs(FrontendResponseModel))
.then(res =>
storeResponse(res, countryCode, dynamoDb, {
source_ip: event.requestContext.identity.sourceIp,
user_agent: event.headers['User-Agent'],
forwarded_for: normalizeForwardedFor(event.headers['X-Forwarded-For']),
}),
)
.then(() => response(200, { success: true }))
.catch(err => response(500, { error: true }, err));
try {
const countryCode = event.headers['CloudFront-Viewer-Country'] || '';
const body = JSON.parse(event.body || '') as unknown;
const res = assertIs(FrontendResponseModel)(body);
await storeResponse(res, countryCode, dynamoDb, {
source_ip: event.requestContext.identity.sourceIp,
user_agent: event.headers['User-Agent'],
forwarded_for: normalizeForwardedFor(event.headers['X-Forwarded-For']),
});
return response(200, { success: true });
} catch (err) {
return response(500, { error: true }, err);
}
} else {
return Promise.resolve(response(200, { name: 'symptomradar', version: APP_VERSION }));
return response(200, { name: 'symptomradar', version: APP_VERSION });
}
};

Expand Down

0 comments on commit 87fcf74

Please sign in to comment.