Merge pull request #35 from billylo1/shc-support

Shc support
This commit is contained in:
Billy Lo 2021-10-01 09:23:44 -04:00 committed by GitHub
commit 8eac24880d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 3350 additions and 2915 deletions

View File

@ -117,16 +117,18 @@ function Form(): JSX.Element {
async function getPayload(file){
try {
const payload = await getPayloadBodyFromFile(file, COLORS.GREEN);
const payload = await getPayloadBodyFromFile(file);
setPayloadBody(payload);
setFileLoading(false);
setFile(file);
if (Object.keys(payload.receipts).length === 1) {
setSelectedDose(parseInt(Object.keys(payload.receipts)[0]));
}else{
setShowDoseOption(true);
}
if (payload.rawData.length == 0) {
if (Object.keys(payload.receipts).length === 1) {
setSelectedDose(parseInt(Object.keys(payload.receipts)[0]));
} else {
setShowDoseOption(true);
}
}
} catch (e) {
setFile(file);
setFileLoading(false);
@ -253,21 +255,30 @@ function Form(): JSX.Element {
try {
if (payloadBody) {
const passName = payloadBody.receipts[selectedDose].name.replace(' ', '-');
const vaxName = payloadBody.receipts[selectedDose].vaccineName.replace(' ', '-');
const passDose = payloadBody.receipts[selectedDose].numDoses;
let selectedReceipt;
if (payloadBody.rawData.length > 0) { // shc stuff
const sortedKeys = Object.keys(payloadBody.receipts).sort(); // pickup the last key in the receipt table
const lastKey = sortedKeys[sortedKeys.length - 1];
selectedReceipt = payloadBody.receipts[lastKey];
} else {
selectedReceipt = payloadBody.receipts[selectedDose];
}
const passName = selectedReceipt.name.replace(' ', '-');
const vaxName = selectedReceipt.vaccineName.replace(' ', '-');
const passDose = selectedReceipt.numDoses;
const covidPassFilename = `grassroots-receipt-${passName}-${vaxName}-${passDose}.pkpass`;
//console.log('> increment count');
console.log('> increment count');
await incrementCount();
// console.log('> generatePass');
console.log('> generatePass');
const pass = await PassData.generatePass(payloadBody, selectedDose);
//console.log('> create blob');
console.log('> create blob');
const passBlob = new Blob([pass], {type: "application/vnd.apple.pkpass"});
//console.log(`> save blob as ${covidPassFilename}`);
console.log(`> save blob as ${covidPassFilename}`);
saveAs(passBlob, covidPassFilename);
setSaveLoading(false);
}
@ -308,13 +319,24 @@ function Form(): JSX.Element {
}
try {
const passName = payloadBody.receipts[selectedDose].name.replace(' ', '-');
const vaxName = payloadBody.receipts[selectedDose].vaccineName.replace(' ', '-');
const passDose = payloadBody.receipts[selectedDose].numDoses;
let selectedReceipt;
if (payloadBody.rawData.length > 0) { // shc stuff
const sortedKeys = Object.keys(payloadBody.receipts).sort(); // pickup the last key in the receipt table
const lastKey = sortedKeys[sortedKeys.length - 1];
selectedReceipt = payloadBody.receipts[lastKey];
setSelectedDose(Number(lastKey));
} else {
selectedReceipt = payloadBody.receipts[selectedDose];
}
const passName = selectedReceipt.name.replace(' ', '-');
const vaxName = selectedReceipt.vaccineName.replace(' ', '-');
const passDose = selectedReceipt.numDoses;
const covidPassFilename = `grassroots-receipt-${passName}-${vaxName}-${passDose}.png`;
await incrementCount();
let photoBlob = await Photo.generatePass(payloadBody, selectedDose);
let photoBlob = await Photo.generatePass(payloadBody, passDose);
saveAs(photoBlob, covidPassFilename);
// need to clean up
@ -420,7 +442,7 @@ function Form(): JSX.Element {
<input type='file'
id='file'
accept="application/pdf"
accept="application/pdf,image/png"
ref={inputFile}
style={{display: 'none'}}
/>

View File

@ -36,7 +36,7 @@ function Page(props: PageProps): JSX.Element {
<a href="https://github.com/billylo1/covidpass" className="underline">{t('common:gitHub')}</a>
<a href="https://vaccine-ontario.ca" className="underline">{t('common:returnToMainSite')}</a>
</nav>
<div className="flex pt-4 flex-row space-x-4 justify-center text-md flex-wrap">Last updated: 2021-09-29 (v1.9.12)</div>
<div className="flex pt-4 flex-row space-x-4 justify-center text-md flex-wrap">Last updated: 2021-10-01 (v1.9.13)</div>
</footer>
</main>
</div>
@ -70,6 +70,14 @@ function Page(props: PageProps): JSX.Element {
<td id='organization' style={{width: 220}}></td>
<td id='vaccinationDate' style={{width:120}}></td>
</tr>
<tr id='extraRow2' hidden>
<td id='organization2' style={{width: 220}}></td>
<td id='vaccinationDate2' style={{width:120}}></td>
</tr>
<tr id='extraRow1' hidden>
<td id='organization1' style={{width: 220}}></td>
<td id='vaccinationDate1' style={{width:120}}></td>
</tr>
<tr style={{height: 20}}></tr>
<tr>
<td><b>NAME</b></td>
@ -89,6 +97,7 @@ function Page(props: PageProps): JSX.Element {
<br/>
<br/>
</div>
<canvas id="canvas" />
</div>
)
}

82
package-lock.json generated
View File

@ -34,6 +34,7 @@
"next-i18next": "^8.5.1",
"next-seo": "^4.26.0",
"node-fetch": "^2.6.1",
"node-jose": "^2.0.0",
"node-pdf-verifier": "^1.0.1",
"pdfjs-dist": "^2.5.207",
"pngjs": "^6.0.0",
@ -911,6 +912,14 @@
}
]
},
"node_modules/base64url": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz",
"integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/big.js": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz",
@ -1732,6 +1741,11 @@
"resolved": "https://registry.npmjs.org/es6-object-assign/-/es6-object-assign-1.1.0.tgz",
"integrity": "sha1-wsNYJlYkfDnqEHyx5mUrb58kUjw="
},
"node_modules/es6-promise": {
"version": "4.2.8",
"resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz",
"integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w=="
},
"node_modules/escalade": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
@ -2709,6 +2723,11 @@
"integrity": "sha1-NhY1Hzu6YZlKCTGYlmC9AyVP0Ak=",
"dev": true
},
"node_modules/long": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz",
"integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA=="
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@ -3061,6 +3080,31 @@
"he": "1.2.0"
}
},
"node_modules/node-jose": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/node-jose/-/node-jose-2.0.0.tgz",
"integrity": "sha512-j8zoFze1gijl8+DK/dSXXqX7+o2lMYv1XS+ptnXgGV/eloQaqq1YjNtieepbKs9jBS4WTnMOqyKSaQuunJzx0A==",
"dependencies": {
"base64url": "^3.0.1",
"buffer": "^5.5.0",
"es6-promise": "^4.2.8",
"lodash": "^4.17.15",
"long": "^4.0.0",
"node-forge": "^0.10.0",
"pako": "^1.0.11",
"process": "^0.11.10",
"uuid": "^3.3.3"
}
},
"node_modules/node-jose/node_modules/uuid": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz",
"integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==",
"deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.",
"bin": {
"uuid": "bin/uuid"
}
},
"node_modules/node-libs-browser": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/node-libs-browser/-/node-libs-browser-2.2.1.tgz",
@ -6000,6 +6044,11 @@
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="
},
"base64url": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz",
"integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A=="
},
"big.js": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz",
@ -6694,6 +6743,11 @@
"resolved": "https://registry.npmjs.org/es6-object-assign/-/es6-object-assign-1.1.0.tgz",
"integrity": "sha1-wsNYJlYkfDnqEHyx5mUrb58kUjw="
},
"es6-promise": {
"version": "4.2.8",
"resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz",
"integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w=="
},
"escalade": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
@ -7412,6 +7466,11 @@
"integrity": "sha1-NhY1Hzu6YZlKCTGYlmC9AyVP0Ak=",
"dev": true
},
"long": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz",
"integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA=="
},
"loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@ -7673,6 +7732,29 @@
"he": "1.2.0"
}
},
"node-jose": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/node-jose/-/node-jose-2.0.0.tgz",
"integrity": "sha512-j8zoFze1gijl8+DK/dSXXqX7+o2lMYv1XS+ptnXgGV/eloQaqq1YjNtieepbKs9jBS4WTnMOqyKSaQuunJzx0A==",
"requires": {
"base64url": "^3.0.1",
"buffer": "^5.5.0",
"es6-promise": "^4.2.8",
"lodash": "^4.17.15",
"long": "^4.0.0",
"node-forge": "^0.10.0",
"pako": "^1.0.11",
"process": "^0.11.10",
"uuid": "^3.3.3"
},
"dependencies": {
"uuid": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz",
"integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A=="
}
}
},
"node-libs-browser": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/node-libs-browser/-/node-libs-browser-2.2.1.tgz",

View File

@ -40,6 +40,7 @@
"next-i18next": "^8.5.1",
"next-seo": "^4.26.0",
"node-fetch": "^2.6.1",
"node-jose": "^2.0.0",
"node-pdf-verifier": "^1.0.1",
"pdfjs-dist": "^2.5.207",
"pngjs": "^6.0.0",
@ -54,6 +55,7 @@
},
"devDependencies": {
"@types/pako": "^1.0.1",
"@types/pngjs": "^6.0.1",
"@types/react": "^17.0.11",
"autoprefixer": "^10.0.4",
"postcss": "^8.1.10",

View File

@ -74,10 +74,11 @@ function Index(): JSX.Element {
<Card content={
<div><p>{t('common:subtitle')}</p><br /><p>{t('common:subtitle2')}</p><br />
<b>{displayPassCount}</b><br/><br/>
Sept 29 afternoon update:
Oct 1 morning update:
<br />
<br />
<ul className="list-decimal list-outside" style={{ marginLeft: '20px' }}>
<li>Foundation improvements</li>
<li>You can now select which page to import for multi-page receipts</li>
<li>System reminders (e.g. unsupported browsers) are now on the top to improve ease of use</li>
</ul><br />

View File

@ -1,48 +1,107 @@
// Taken from https://github.com/ehn-dcc-development/ehn-sign-verify-javascript-trivial/blob/main/cose_verify.js
// and https://github.com/ehn-dcc-development/dgc-check-mobile-app/blob/main/src/app/cose-js/sign.js
// adapted from https://github.com/fproulx/shc-covid19-decoder/blob/main/src/shc.js
import base45 from 'base45';
import pako from 'pako';
import cbor from 'cbor-js';
const jsQR = require("jsqr");
const zlib = require("zlib");
import {Receipt, HashTable} from "./payload";
export function typedArrayToBufferSliced(array: Uint8Array): ArrayBuffer {
return array.buffer.slice(array.byteOffset, array.byteLength + array.byteOffset);
export function getQRFromImage(imageData) {
return jsQR(
new Uint8ClampedArray(imageData.data.buffer),
imageData.width,
imageData.height
);
}
export function typedArrayToBuffer(array: Uint8Array): ArrayBuffer {
var buffer = new ArrayBuffer(array.length);
// vaccine codes based on Alex Dunae's findings
// https://gist.github.com/alexdunae/49cc0ea95001da3360ad6896fa5677ec
// http://mchp-appserv.cpe.umanitoba.ca/viewConcept.php?printer=Y&conceptID=1514
array.map(function (value, i) {
return buffer[i] = value;
})
// .vc.credentialSubject.fhirBundle.entry
export function decodedStringToReceipt(decoded: object) : HashTable<Receipt> {
return array.buffer;
}
const codeToVaccineName = {
'28581000087106': 'PFIZER',
'28951000087107': 'JANSSEN',
'28761000087108': 'ASTRAZENECA',
'28571000087109': 'MODERNA'
}
export function decodeData(data: string): Object {
const cvxCodeToVaccineName = { // https://www2a.cdc.gov/vaccines/iis/iisstandards/vaccines.asp?rpt=cvx
'208': 'PFIZER',
'212': 'JANSSEN',
'210': 'ASTRAZENECA',
'207': 'MODERNA'
}
if (data.startsWith('HC1')) {
data = data.substring(3);
// console.log(decoded);
const shcResources = decoded['vc'].credentialSubject.fhirBundle.entry;
let issuer;
if (decoded['iss'].includes('quebec.ca')) {
issuer = 'qc';
}
if (decoded['iss'].includes('ontariohealth.ca')) {
issuer = 'on';
}
if (decoded['iss'] == "https://smarthealthcard.phsa.ca/v1/issuer") {
issuer = 'bc';
}
if (data.startsWith(':')) {
data = data.substring(1);
let name = '';
let dateOfBirth;
let receipts : HashTable<Receipt> = {};
const numResources = shcResources.length;
for (let i = 0; i < numResources; i++) {
const resource = shcResources[i]['resource'];
if (resource["resourceType"] == 'Patient') {
if (name.length > 0)
name += '\n';
for (const nameField of resource.name) {
for (const given of nameField.given) {
name += (given + ' ')
}
name += (nameField.family);
}
dateOfBirth = resource['birthDate'];
}
if (resource["resourceType"] == 'Immunization') {
let vaccineName : string;
let organizationName : string;
let vaccinationDate : string;
for (const vaccineCodes of resource.vaccineCode.coding) {
if (vaccineCodes.system.includes("snomed.info")) { //bc
vaccineName = codeToVaccineName[vaccineCodes.code];
if (vaccineName == undefined)
vaccineName = 'Unknown - ' + vaccineCodes.code;
} else if (vaccineCodes.system == "http://hl7.org/fhir/sid/cvx") { //qc
vaccineName = cvxCodeToVaccineName[vaccineCodes.code];
if (vaccineName == undefined)
vaccineName = 'Unknown - ' + vaccineCodes.code;
}
}
let performers = resource['performer']; // BC
let receiptNumber;
if (issuer == 'bc') {
performers = resource['performer'];
receiptNumber = shcResources[i]['fullUrl'].split(':')[1];
for (let j = 0; j < performers.length; j++) {
const performer = performers[j];
organizationName = performer.actor.display;
}
}
if (issuer == 'qc') {
organizationName = resource['location'].display; // QC
receiptNumber = resource['protocolApplied'].doseNumber;
}
vaccinationDate = resource.occurrenceDateTime;
const receipt = new Receipt(name, vaccinationDate, vaccineName, dateOfBirth, receiptNumber, organizationName);
// console.log(receipt);
receipts[receiptNumber] = receipt;
}
}
return receipts;
var arrayBuffer: Uint8Array = base45.decode(data);
if (arrayBuffer[0] == 0x78) {
arrayBuffer = pako.inflate(arrayBuffer);
}
var payloadArray: Array<Uint8Array> = cbor.decode(typedArrayToBuffer(arrayBuffer));
if (!Array.isArray(payloadArray) || payloadArray.length !== 4) {
throw new Error('decodingFailed');
}
var plaintext: Uint8Array = payloadArray[2];
var decoded: Object = cbor.decode(typedArrayToBufferSliced(plaintext));
return decoded;
}

76
src/issuers.js Normal file
View File

@ -0,0 +1,76 @@
const issuers = [
{
id: "ca.qc",
iss: "https://covid19.quebec.ca/PreuveVaccinaleApi/issuer",
keys: [
{ kid: "qFdl0tDZK9JAWP6g9_cAv57c3KWxMKwvxCrRVSzcxvM",
alg: "ES256", kty: "EC", crv: "P-256", use: "sig",
x: "XSxuwW_VI_s6lAw6LAlL8N7REGzQd_zXeIVDHP_j_Do",
y: "88-aI4WAEl4YmUpew40a9vq_w5OcFvsuaKMxJRLRLL0" },
]
},
{
id: "us.ca",
iss: "https://myvaccinerecord.cdph.ca.gov/creds",
keys: [
{ kid: "7JvktUpf1_9NPwdM-70FJT3YdyTiSe2IvmVxxgDSRb0",
alg: "ES256", kty: "EC", crv: "P-256", use: "sig",
x: "3dQz5ZlbazChP3U7bdqShfF0fvSXLXD9WMa1kqqH6i4",
y: "FV4AsWjc7ZmfhSiHsw2gjnDMKNLwNqi2jMLmJpiKWtE" },
]
},
{
id: "us.ny",
iss: "https://ekeys.ny.gov/epass/doh/dvc/2021",
keys: [
{ kid: "9ENs36Gsu-GmkWIyIH9XCozU9BFhLeaXvwrT3B97Wok",
alg: "ES256", kty: "EC", crv: "P-256", use: "sig",
x: "--M0AedrNg31sHZGAs6qg7WU9LwnDCMWmd6KjiKfrZU",
y: "rSf2dKerJFW3-oUNcvyrI2x39hV2EbazORZvh44ukjg" },
]
},
{
id: "us.la",
iss: "https://healthcardcert.lawallet.com",
keys: [
{ kid: "UOvXbgzZj4zL-lt1uJVS_98NHQrQz48FTdqQyNEdaNE",
alg: "ES256", kty: "EC", crv: "P-256", use: "sig",
x: "n1PxhSk7DQj8ZBK3VIfwhlcN__QG357gbiTfZYt1gn8",
y: "ZDGv5JYe4mCm75HCsHG8UkIyffr1wcZMwJjH8v5cGCc" },
]
},
{
id: "ca.yt",
iss: "https://pvc.service.yukon.ca/issuer",
keys: [
{ kid: "UnHGY-iyCIr__dzyqcxUiApMwU9lfeXnzT2i5Eo7TvE",
alg: "ES256", kty: "EC", crv: "P-256", use: "sig",
x: "wCeT9rdLYTpOK52OK0-oRbwDxbljJdNiDuxPsPt_1go",
y: "IgFPi1OrHtJWJGwPMvlueeHmULUKEpScgpQtoHNjX-Q" },
]
},
{
id: "ca.bc",
iss: "https://smarthealthcard.phsa.ca/v1/issuer",
keys: [
{ kid: "XCqxdhhS7SWlPqihaUXovM_FjU65WeoBFGc_ppent0Q",
alg: "ES256", kty: "EC", crv: "P-256", use: "sig",
x: "xscSbZemoTx1qFzFo-j9VSnvAXdv9K-3DchzJvNnwrY",
y: "jA5uS5bz8R2nxf_TU-0ZmXq6CKWZhAG1Y4icAx8a9CA" },
]
},
{
id: "ca.sk",
iss: "https://skphr.prd.telushealthspace.com",
keys: [
{ kid: "xOqUO82bEz8APn_5wohZZvSK4Ui6pqWdSAv5BEhkes0",
alg: "ES256", kty: "EC", crv: "P-256", use: "sig",
x: "Hk4ktlNfoIIo7jp5I8cefp54Ils3TsKvKXw_E9CGIPE",
y: "7hVieFGuHJeaNRCxVgKeVpoxDJevytgoCxqVZ6cfcdk" },
]
},
];
module.exports = {
issuers,
};

View File

@ -5,26 +5,10 @@ import {Constants} from "./constants";
import {Payload, PayloadBody, PassDictionary} from "./payload";
import * as Sentry from '@sentry/react';
import { QRCodeMatrixUtil } from '@zxing/library';
import {QrCode,Encoding,PackageResult,QrFormat,PassPhotoCommon} from './passphoto-common';
const crypto = require('crypto')
enum QrFormat {
PKBarcodeFormatQR = 'PKBarcodeFormatQR',
PKBarcodeFormatPDF417 = 'PKBarcodeFormatPDF417'
}
enum Encoding {
utf8 = "utf-8",
iso88591 = "iso-8859-1"
}
interface QrCode {
message: string;
format: QrFormat;
messageEncoding: Encoding;
// altText: string;
}
interface SignData {
PassJsonHash: string;
useBlackVersion: boolean;
@ -85,67 +69,12 @@ export class PassData {
// Create Payload
try {
const payload: Payload = new Payload(payloadBody, numDose);
payload.serialNumber = uuid4();
// register record
const clonedReceipt = Object.assign({}, payload.receipt);
delete clonedReceipt.name;
delete clonedReceipt.dateOfBirth;
clonedReceipt["serialNumber"] = payload.serialNumber;
clonedReceipt["type"] = 'applewallet';
let requestOptions = {
method: 'POST', // *GET, POST, PUT, DELETE, etc.
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(clonedReceipt) // body data type must match "Content-Type" header
}
// console.log('registering ' + JSON.stringify(clonedReceipt, null, 2));
const configResponse = await fetch('/api/config');
const configResponseJson = await configResponse.json();
const verifierHost = configResponseJson.verifierHost;
const registrationHost = configResponseJson.registrationHost;
let functionSuffix = configResponseJson.functionSuffix;
if (functionSuffix == undefined)
functionSuffix = '';
const registerUrl = `${registrationHost}/register${functionSuffix}`;
// console.log(registerUrl);
const response = await fetch(registerUrl, requestOptions);
const responseJson = await response.json();
console.log(JSON.stringify(responseJson,null,2));
if (responseJson["result"] != 'OK') {
console.error(responseJson);
return Promise.reject();
}
const encodedUri = `serialNumber=${encodeURIComponent(payload.serialNumber)}&vaccineName=${encodeURIComponent(payload.receipt.vaccineName)}&vaccinationDate=${encodeURIComponent(payload.receipt.vaccinationDate)}&organization=${encodeURIComponent(payload.receipt.organization)}&dose=${encodeURIComponent(payload.receipt.numDoses)}`;
const qrCodeUrl = `${verifierHost}/verify?${encodedUri}`;
// console.log(qrCodeUrl);
// Create QR Code Object
const qrCode: QrCode = {
message: qrCodeUrl,
format: QrFormat.PKBarcodeFormatQR,
messageEncoding: Encoding.iso88591,
// altText : payload.rawData
}
const results = await PassPhotoCommon.preparePayload(payloadBody, numDose);
const payload = results.payload;
// Create pass data
const pass: PassData = new PassData(payload, qrCode);
const pass: PassData = new PassData(results.payload, results.qrCode);
// Create new zip
const zip = [] as { path: string; data: Buffer | string }[];
@ -197,8 +126,7 @@ export class PassData {
return createZip(zip);
} catch (e) {
Sentry.captureException(e);
return Promise.reject();
return Promise.reject(e);
}
}

109
src/passphoto-common.ts Normal file
View File

@ -0,0 +1,109 @@
import {toBuffer as createZip} from 'do-not-zip';
import {v4 as uuid4} from 'uuid';
import {Constants} from "./constants";
import {Payload, PayloadBody, PassDictionary} from "./payload";
import * as Sentry from '@sentry/react';
import { QRCodeMatrixUtil } from '@zxing/library';
export enum QrFormat {
PKBarcodeFormatQR = 'PKBarcodeFormatQR',
PKBarcodeFormatPDF417 = 'PKBarcodeFormatPDF417'
}
export enum Encoding {
utf8 = "utf-8",
iso88591 = "iso-8859-1"
}
export interface QrCode {
message: string;
format: QrFormat;
messageEncoding: Encoding;
// altText: string;
}
export interface PackageResult {
payload: Payload;
qrCode: QrCode;
}
export class PassPhotoCommon {
static async preparePayload(payloadBody: PayloadBody, numDose: number) : Promise<PackageResult> {
console.log('preparePayload');
// console.log(JSON.stringify(payloadBody, null, 2), numDose);
const payload: Payload = new Payload(payloadBody, numDose);
payload.serialNumber = uuid4();
let qrCodeMessage;
if (payloadBody.rawData.startsWith('shc:/')) {
qrCodeMessage = payloadBody.rawData;
} else {
// register record
const clonedReceipt = Object.assign({}, payloadBody.receipts[numDose]);
delete clonedReceipt.name;
delete clonedReceipt.dateOfBirth;
clonedReceipt["serialNumber"] = payload.serialNumber;
clonedReceipt["type"] = 'applewallet';
let requestOptions = {
method: 'POST', // *GET, POST, PUT, DELETE, etc.
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(clonedReceipt) // body data type must match "Content-Type" header
}
// console.log('registering ' + JSON.stringify(clonedReceipt, null, 2));
const configResponse = await fetch('/api/config');
const configResponseJson = await configResponse.json();
const verifierHost = configResponseJson.verifierHost;
const registrationHost = configResponseJson.registrationHost;
let functionSuffix = configResponseJson.functionSuffix;
if (functionSuffix == undefined)
functionSuffix = '';
const registerUrl = `${registrationHost}/register${functionSuffix}`;
// console.log(registerUrl);
const response = await fetch(registerUrl, requestOptions);
const responseJson = await response.json();
// console.log(JSON.stringify(responseJson,null,2));
if (responseJson["result"] != 'OK') {
console.error(responseJson);
return Promise.reject();
}
const encodedUri = `serialNumber=${encodeURIComponent(payload.serialNumber)}&vaccineName=${encodeURIComponent(payloadBody.receipts[numDose].vaccineName)}&vaccinationDate=${encodeURIComponent(payloadBody.receipts[numDose].vaccinationDate)}&organization=${encodeURIComponent(payloadBody.receipts[numDose].organization)}&dose=${encodeURIComponent(payloadBody.receipts[numDose].numDoses)}`;
const qrCodeUrl = `${verifierHost}/verify?${encodedUri}`;
qrCodeMessage = qrCodeUrl;
// console.log(qrCodeUrl);
}
// Create QR Code Object
const qrCode: QrCode = {
message: qrCodeMessage,
format: QrFormat.PKBarcodeFormatQR,
messageEncoding: Encoding.iso88591,
// altText : payload.rawData
}
return {payload: payload, qrCode: qrCode}
}
}

View File

@ -1,5 +1,6 @@
import {Constants} from "./constants";
import {COLORS} from "./colors";
import { TEXT_ALIGN } from "html2canvas/dist/types/css/property-descriptors/text-align";
export class Receipt {
constructor(public name: string, public vaccinationDate: string, public vaccineName: string, public dateOfBirth: string, public numDoses: number, public organization: string) {};
@ -36,7 +37,7 @@ export interface PayloadBody {
export class Payload {
receipt: Receipt;
receipts: HashTable<Receipt>;
rawData: string;
backgroundColor: string;
labelColor: string;
@ -48,10 +49,51 @@ export class Payload {
constructor(body: PayloadBody, numDose: number) {
// Get name and date of birth information
const name = body.receipts[numDose].name;
const dateOfBirth = body.receipts[numDose].dateOfBirth;
const vaccineName = body.receipts[numDose].vaccineName;
let generic: PassDictionary = {
headerFields: [],
primaryFields: [],
secondaryFields: [],
auxiliaryFields: [],
backFields: []
}
this.backgroundColor = COLORS.YELLOW;
this.labelColor = COLORS.WHITE
this.foregroundColor = COLORS.WHITE
this.img1x = Constants.img1xWhite
this.img2x = Constants.img2xWhite
let fullyVaccinated = false;
var keys = Object.keys(body.receipts).reverse();
if (body.rawData.length > 0) { // SHC contains multiple receipts
for (let k of keys) {
fullyVaccinated = processReceipt(body.receipts[k], generic);
if (fullyVaccinated) {
this.backgroundColor = COLORS.GREEN;
}
}
} else {
fullyVaccinated = processReceipt(body.receipts[numDose], generic);
if (fullyVaccinated) {
this.backgroundColor = COLORS.GREEN;
}
}
this.receipts = body.receipts;
this.rawData = body.rawData;
this.generic = generic;
}
}
function processReceipt(receipt: Receipt, generic: PassDictionary) : boolean {
console.log('processing receipt #' + receipt.numDoses);
const name = receipt['name'];
const dateOfBirth = receipt.dateOfBirth;
const numDoses = receipt.numDoses;
const vaccineName = receipt.vaccineName.toLocaleUpperCase();
let vaccineNameProper = vaccineName.charAt(0) + vaccineName.substr(1).toLowerCase();
if (vaccineName.includes('PFIZER'))
@ -63,42 +105,58 @@ export class Payload {
if (vaccineName.includes('ASTRAZENECA') || vaccineName.includes('COVISHIELD'))
vaccineNameProper = 'AstraZeneca (Vaxzevria)'
let doseVaccine = "#" + String(body.receipts[numDose].numDoses) + ": " + vaccineNameProper;
if (name == undefined) {
throw new Error('nameMissing');
}
if (dateOfBirth == undefined) {
throw new Error('dobMissing');
let doseVaccine = "#" + String(receipt.numDoses) + ": " + vaccineNameProper;
let fullyVaccinated = false;
if (receipt.numDoses > 1 ||
vaccineName.toLowerCase().includes('janssen') ||
vaccineName.toLowerCase().includes('johnson') ||
vaccineName.toLowerCase().includes('j&j')) {
fullyVaccinated = true;
}
const generic: PassDictionary = {
headerFields: [
],
primaryFields: [
if (generic.primaryFields.length == 0) {
generic.primaryFields.push(
{
key: "vaccine",
label: "Vaccine",
value: doseVaccine,
key: "vaccine",
label: "Vaccine",
value: doseVaccine
}
)
}
],
secondaryFields: [
{
let fieldToPush = generic.secondaryFields;
if (fieldToPush.length > 0) {
fieldToPush = generic.backFields;
generic.headerFields.push({
key: "extra",
label: "More",
value: "(i)",
"textAlignment" : "PKTextAlignmentCenter"
});
generic.backFields.push({
key: "vaccine" + numDoses,
label: `Vaccine (Dose ${numDoses})`,
value: receipt.vaccineName
})
}
fieldToPush.push(
{
key: "issuer",
label: "Authorized Organization",
value: body.receipts[numDose].organization
},
value: receipt.organization
},
{
key: "dov",
label: "Date",
value: body.receipts[numDose].vaccinationDate,
// textAlignment: TextAlignment.right
value: receipt.vaccinationDate,
}
],
auxiliaryFields: [
{
);
if (generic.auxiliaryFields.length == 0) {
generic.auxiliaryFields.push(
{
key: "name",
label: "Name",
value: name
@ -107,33 +165,7 @@ export class Payload {
key: "dob",
label: "Date of Birth",
value: dateOfBirth
}
],
backFields: [
//TODO: add url link back to grassroots site
]
});
}
// Set Values
this.receipt = body.receipts[numDose];
this.rawData = body.rawData;
if (body.receipts[numDose].numDoses > 1 || body.receipts[numDose].vaccineName.toLowerCase().includes('janssen') || body.receipts[numDose].vaccineName.toLowerCase().includes('johnson') || body.receipts[numDose].vaccineName.toLowerCase().includes('j&j')) {
this.backgroundColor = COLORS.GREEN;
} else {
this.backgroundColor = COLORS.YELLOW;
}
this.labelColor = COLORS.WHITE
this.foregroundColor = COLORS.WHITE
this.img1x = Constants.img1xWhite
this.img2x = Constants.img2xWhite
this.generic = generic;
return fullyVaccinated;
}
}

View File

@ -4,25 +4,11 @@ import {v4 as uuid4} from 'uuid';
import {BrowserQRCodeSvgWriter} from "@zxing/browser";
import { toPng, toJpeg, toBlob, toPixelData, toSvg } from 'html-to-image';
import * as Sentry from '@sentry/react';
enum QrFormat {
PKBarcodeFormatQR = 'PKBarcodeFormatQR',
PKBarcodeFormatPDF417 = 'PKBarcodeFormatPDF417'
}
enum Encoding {
utf8 = "utf-8",
iso88591 = "iso-8859-1"
}
interface QrCode {
message: string;
format: QrFormat;
messageEncoding: Encoding;
// altText: string;
}
import {QrCode,Encoding,PackageResult,QrFormat,PassPhotoCommon} from './passphoto-common';
import { EncodeHintType } from "@zxing/library";
export class Photo {
logoText: string = Constants.NAME;
organizationName: string = Constants.NAME;
description: string = Constants.NAME;
@ -33,99 +19,62 @@ export class Photo {
barcodes: Array<QrCode>;
barcode: QrCode;
static async generatePass(payloadBody: PayloadBody, numDose: number): Promise<Blob> {
// Create Payload
try {
const payload: Payload = new Payload(payloadBody, numDose);
console.log('generatePass');
const results = await PassPhotoCommon.preparePayload(payloadBody, numDose);
const payload = results.payload;
const qrCode = results.qrCode;
payload.serialNumber = uuid4();
// register record
const clonedReceipt = Object.assign({}, payload.receipt);
delete clonedReceipt.name;
delete clonedReceipt.dateOfBirth;
clonedReceipt["serialNumber"] = payload.serialNumber;
clonedReceipt["type"] = 'photo';
let requestOptions = {
method: 'POST', // *GET, POST, PUT, DELETE, etc.
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(clonedReceipt) // body data type must match "Content-Type" header
let receipt;
if (results.payload.rawData.length == 0) {
receipt = results.payload.receipts[numDose];
} else {
receipt = results.payload.receipts[numDose];
}
console.log('registering ' + JSON.stringify(clonedReceipt, null, 2));
const configResponse = await fetch('/api/config')
const verifierHost = (await configResponse.json()).verifierHost
// const verifierHost = 'https://verifier.vaccine-ontario.ca';
const response = await fetch('https://us-central1-grassroot-verifier.cloudfunctions.net/register', requestOptions);
const responseJson = await response.json();
console.log(JSON.stringify(responseJson,null,2));
if (responseJson["result"] != 'OK')
return Promise.reject();
const encodedUri = `serialNumber=${encodeURIComponent(payload.serialNumber)}&vaccineName=${encodeURIComponent(payload.receipt.vaccineName)}&vaccinationDate=${encodeURIComponent(payload.receipt.vaccinationDate)}&organization=${encodeURIComponent(payload.receipt.organization)}&dose=${encodeURIComponent(payload.receipt.numDoses)}`;
const qrCodeUrl = `${verifierHost}/verify?${encodedUri}`;
// Create QR Code Object
const qrCode: QrCode = {
message: qrCodeUrl,
format: QrFormat.PKBarcodeFormatQR,
messageEncoding: Encoding.iso88591,
// altText : payload.rawData
}
// Create photo
// const photo: Photo = new Photo(payload, qrCode);
// const body = domTree.getElementById('main');
const body = document.getElementById('pass-image');
body.hidden = false;
body.style.backgroundColor = payload.backgroundColor
const name = payload.receipt.name;
const dateOfBirth = payload.receipt.dateOfBirth;
const vaccineName = payload.receipt.vaccineName;
let vaccineNameProper = vaccineName.charAt(0) + vaccineName.substr(1).toLowerCase();
if (vaccineName.includes('PFIZER'))
vaccineNameProper = 'Pfizer (Comirnaty)'
if (vaccineName.includes('MODERNA'))
vaccineNameProper = 'Moderna (SpikeVax)'
if (vaccineName.includes('ASTRAZENECA') || vaccineName.includes('COVISHIELD'))
vaccineNameProper = 'AstraZeneca (Vaxzevria)'
let doseVaccine = "#" + String(payload.receipt.numDoses) + ": " + vaccineNameProper;
const vaccineName = receipt.vaccineName;
let doseVaccine = "#" + String(receipt.numDoses) + ": " + vaccineName;
document.getElementById('vaccineName').innerText = doseVaccine;
document.getElementById('vaccinationDate').innerText = payload.receipt.vaccinationDate;
document.getElementById('organization').innerText = payload.receipt.organization;
document.getElementById('name').innerText = payload.receipt.name;
document.getElementById('dob').innerText = payload.receipt.dateOfBirth;
document.getElementById('vaccinationDate').innerText = receipt.vaccinationDate;
document.getElementById('organization').innerText = receipt.organization;
document.getElementById('name').innerText = receipt.name;
document.getElementById('dob').innerText = receipt.dateOfBirth;
if ((results.payload.rawData.length != 0) && (numDose > 1)) {
for (let i = 1; i < numDose; i++) {
console.log(i);
receipt = results.payload.receipts[i];
document.getElementById('extraRow' + i ).hidden = false;
document.getElementById('vaccinationDate' + i).innerText = receipt.vaccinationDate;
document.getElementById('organization' + i).innerText = receipt.organization;
}
}
const codeWriter = new BrowserQRCodeSvgWriter();
const svg = codeWriter.write(qrCode.message,200,200);
const hints : Map<EncodeHintType,any> = new Map().set(EncodeHintType.ERROR_CORRECTION,'L');
const svg = codeWriter.write(qrCode.message,200,200, hints);
svg.setAttribute('style','background-color: white');
document.getElementById('qrcode').appendChild(svg);
const blobPromise = toBlob(body);
return blobPromise;
} catch (e) {
Sentry.captureException(e);
return Promise.reject();
return Promise.reject(e);
}
}

View File

@ -1,10 +1,14 @@
import {PayloadBody, Receipt, HashTable} from "./payload";
import * as PdfJS from 'pdfjs-dist'
import jsQR, {QRCode} from "jsqr";
import { getCertificatesInfoFromPDF } from "@ninja-labs/verify-pdf"; // ES6
import {COLORS} from "./colors";
import { getCertificatesInfoFromPDF } from "@ninja-labs/verify-pdf"; // ES6
import * as Sentry from '@sentry/react';
import * as Decode from './decode';
import {getScannedJWS, verifyJWS, decodeJWS} from "./shc";
import { PNG } from 'pngjs/browser';
import { TextItem } from "pdfjs-dist/types/display/api";
import { PDFPageProxy, TextContent, TextItem } from "pdfjs-dist/types/display/api";
// import {PNG} from 'pngjs'
// import {decodeData} from "./decode";
@ -12,33 +16,67 @@ import { TextItem } from "pdfjs-dist/types/display/api";
PdfJS.GlobalWorkerOptions.workerSrc = `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${PdfJS.version}/pdf.worker.js`
export async function getPayloadBodyFromFile(file: File, color: COLORS): Promise<PayloadBody> {
export async function getPayloadBodyFromFile(file: File): Promise<PayloadBody> {
// Read file
const fileBuffer = await file.arrayBuffer();
let receipts: HashTable<Receipt>;
let rawData = ''; // unused at the moment, the original use was to store the QR code from issuer
switch (file.type) {
case 'application/pdf':
receipts = await loadPDF(fileBuffer)
const receiptType = await detectReceiptType(fileBuffer);
console.log(`receiptType = ${receiptType}`);
if (receiptType == 'ON') {
receipts = await loadPDF(fileBuffer) // receipt type is needed to decide if digital signature checking is needed
} else {
const shcData = await processSHC(fileBuffer);
receipts = shcData.receipts;
rawData = shcData.rawData;
}
break
default:
throw Error('invalidFileType')
}
const rawData = ''; // unused at the moment, the original use was to store the QR code from issuer
return {
receipts: receipts,
rawData: rawData
}
}
async function loadPDF(signedPdfBuffer : ArrayBuffer): Promise<HashTable<Receipt>> {
async function detectReceiptType(fileBuffer : ArrayBuffer): Promise<string> {
// Ontario has 'COVID-19 vaccination receipt'
// BC has BC Vaccine Card
console.log('detectPDFTypeAndProcess');
const typedArray = new Uint8Array(fileBuffer);
let loadingTask = PdfJS.getDocument(typedArray);
const pdfDocument = await loadingTask.promise;
const pdfPage = await pdfDocument.getPage(1); //first page
const content = await pdfPage.getTextContent();
const numItems = content.items.length;
for (let i = 0; i < numItems; i++) {
let item = content.items[i] as TextItem;
const value = item.str;
console.log(value);
if (value.includes('COVID-19 vaccination receipt')) {
console.log('detected on');
return Promise.resolve('ON');
}
}
return Promise.resolve('SHC');
}
async function loadPDF(fileBuffer : ArrayBuffer): Promise<HashTable<Receipt>> {
try {
const certs = getCertificatesInfoFromPDF(signedPdfBuffer);
const certs = getCertificatesInfoFromPDF(fileBuffer);
const result = certs[0];
const refcert = '-----BEGIN CERTIFICATE-----\r\n'+
@ -95,7 +133,7 @@ async function loadPDF(signedPdfBuffer : ArrayBuffer): Promise<HashTable<Receipt
if (( issuedpemCertificate )) {
//console.log('getting receipt details inside PDF');
const receipt = await getPdfDetails(signedPdfBuffer);
const receipt = await getPdfDetails(fileBuffer);
// console.log(JSON.stringify(receipt, null, 2));
return Promise.resolve(receipt);
@ -110,13 +148,16 @@ async function loadPDF(signedPdfBuffer : ArrayBuffer): Promise<HashTable<Receipt
console.error('invalid certificate');
return Promise.reject(`invalid certificate + ${JSON.stringify(result)}`);
}
} catch (e) {
console.error(e);
Sentry.captureException(e);
if (e.message.includes('Failed to locate ByteRange')) {
e.message = 'Sorry. Selected PDF file is not digitally signed. Please download official copy from Step 1 and retry. Thanks.'
} else {
Sentry.captureException(e);
}
return Promise.reject(e);
}
@ -159,6 +200,7 @@ async function getPdfDetails(fileBuffer: ArrayBuffer): Promise<HashTable<Receipt
numDoses = Number(value.split(' ')[3]);
}
receiptObj[numDoses] = new Receipt(name, vaccinationDate, vaccineName, dateOfBirth, numDoses, organization);
console.log(receiptObj[numDoses]);
}
return Promise.resolve(receiptObj);
@ -167,3 +209,65 @@ async function getPdfDetails(fileBuffer: ArrayBuffer): Promise<HashTable<Receipt
return Promise.reject(e);
}
}
async function getImageDataFromPdf(pdfPage: PDFPageProxy): Promise<ImageData> {
const pdfScale = 2;
const canvas = <HTMLCanvasElement>document.getElementById('canvas');
const canvasContext = canvas.getContext('2d');
const viewport = pdfPage.getViewport({scale: pdfScale})
// Set correct canvas width / height
canvas.width = viewport.width
canvas.height = viewport.height
// render PDF
const renderTask = pdfPage.render({
canvasContext: canvasContext,
viewport,
})
await renderTask.promise;
// Return PDF Image Data
return canvasContext.getImageData(0, 0, canvas.width, canvas.height)
}
async function processSHC(fileBuffer : ArrayBuffer) : Promise<any> {
console.log('processSHC');
try {
const typedArray = new Uint8Array(fileBuffer);
let loadingTask = PdfJS.getDocument(typedArray);
const pdfDocument = await loadingTask.promise;
// Load all dose numbers
const pdfPage = await pdfDocument.getPage(1);
const imageData = await getImageDataFromPdf(pdfPage);
const code : QRCode = await Decode.getQRFromImage(imageData);
let rawData = code.data;
const jws = getScannedJWS(rawData);
let decoded = await decodeJWS(jws);
// console.log(decoded);
const verified = verifyJWS(jws, decoded.iss);
if (verified) {
let receipts = Decode.decodedStringToReceipt(decoded);
console.log(receipts);
return Promise.resolve({receipts: receipts, rawData: rawData});
} else {
return Promise.reject(`Issuer ${decoded.iss} cannot be verified.`);
}
} catch (e) {
Promise.reject(e);
}
}

View File

@ -8,7 +8,9 @@ export const initSentry = () => {
integrations: [
new Integrations.BrowserTracing(),
],
attachStacktrace: true
attachStacktrace: true,
tracesSampleRate: 0.5
});
console.log('sentry initialized');

78
src/shc.js Normal file
View File

@ -0,0 +1,78 @@
const jose = require("node-jose");
const jsQR = require("jsqr");
const zlib = require("zlib");
const { issuers } = require("./issuers");
function getQRFromImage(imageData) {
return jsQR(
new Uint8ClampedArray(imageData.data.buffer),
imageData.width,
imageData.height
);
}
function getScannedJWS(shcString) {
try {
return shcString
.match(/^shc:\/(.+)$/)[1]
.match(/(..?)/g)
.map((num) => String.fromCharCode(parseInt(num, 10) + 45))
.join("");
} catch (e) {
error = new Error("parsing shc string failed");
error.cause = e;
throw error;
}
}
function verifyJWS(jws, iss) {
const issuer = issuers.find(el => el.iss === iss);
if (!issuer) {
error = new Error("Unknown issuer " + iss);
error.customMessage = true;
return Promise.reject(error);
}
return jose.JWK.asKeyStore({ keys: issuer.keys }).then(function (keyStore) {
const { verify } = jose.JWS.createVerify(keyStore);
console.log("jws", jws);
return verify(jws);
});
}
function decodeJWS(jws) {
try {
const payload = jws.split(".")[1];
return decodeJWSPayload(Buffer.from(payload, "base64"));
} catch (e) {
error = new Error("decoding payload failed");
error.cause = e;
throw error;
}
}
function decodeJWSPayload(decodedPayload) {
return new Promise((resolve, reject) => {
zlib.inflateRaw(decodedPayload, function (err, decompressedResult) {
if (typeof err === "object" && err) {
console.log("Unable to decompress");
reject(err);
} else {
try {
console.log(decompressedResult);
scannedResult = decompressedResult.toString("utf8");
resolve(JSON.parse(scannedResult));
} catch (e) {
reject(e);
}
}
});
});
}
module.exports = {
getQRFromImage,
getScannedJWS,
verifyJWS,
decodeJWS,
decodeJWSPayload,
};

5228
yarn.lock

File diff suppressed because it is too large Load Diff