Merge pull request #40 from billylo1/shc-refactoring
Add full SHC card generation support to the app
This commit is contained in:
commit
84c1a91da2
|
@ -21,8 +21,8 @@ yarn dev
|
|||
### Run the Docker container
|
||||
|
||||
```sh
|
||||
docker build . -t covidpass
|
||||
docker run -t -i -p 3000:3000 covidpass
|
||||
docker build . -t covidpass -t gcr.io/broadcast2patients/covidpass
|
||||
docker run --rm -t -i -p 3000:3000 covidpass
|
||||
```
|
||||
|
||||
### Integration with other repos required
|
||||
|
@ -33,7 +33,7 @@ docker run -t -i -p 3000:3000 covidpass
|
|||
|
||||
#### I do not want to trust a third party with my vaccination data, does this tool respect my privacy?
|
||||
|
||||
Processing of your data happens entirely in your browser and only a hashed representation is sent to the server for the signing step.
|
||||
Processing of your data happens entirely in your browser and only a hashed representation is sent to the server for the signing step. For more details of this, please see https://toronto.ctvnews.ca/video?clipId=2294461
|
||||
|
||||
#### How do I make sure that nobody can access my vaccination pass from the lock screen (iOS)?
|
||||
|
||||
|
|
|
@ -263,28 +263,26 @@ function Form(): JSX.Element {
|
|||
if (payloadBody) {
|
||||
|
||||
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];
|
||||
let filenameDetails = '';
|
||||
if (payloadBody.rawData.length > 0) {
|
||||
// This is an SHC receipt, so do our SHC thing
|
||||
selectedReceipt = payloadBody.shcReceipt;
|
||||
filenameDetails = selectedReceipt.cardOrigin.replace(' ', '-');
|
||||
} else {
|
||||
selectedReceipt = payloadBody.receipts[selectedDose];
|
||||
const vaxName = selectedReceipt.vaccineName.replace(' ', '-');
|
||||
const passDose = selectedReceipt.numDoses;
|
||||
filenameDetails = `${vaxName}-${passDose}`;
|
||||
}
|
||||
const passName = selectedReceipt.name.replace(' ', '-');
|
||||
const vaxName = selectedReceipt.vaccineName.replace(' ', '-');
|
||||
const passDose = selectedReceipt.numDoses;
|
||||
const covidPassFilename = `grassroots-receipt-${passName}-${vaxName}-${passDose}.pkpass`;
|
||||
const covidPassFilename = `grassroots-receipt-${passName}-${filenameDetails}.pkpass`;
|
||||
|
||||
console.log('> increment count');
|
||||
await incrementCount();
|
||||
|
||||
console.log('> generatePass');
|
||||
const pass = await PassData.generatePass(payloadBody, selectedDose);
|
||||
|
||||
console.log('> create blob');
|
||||
const passBlob = new Blob([pass], {type: "application/vnd.apple.pkpass"});
|
||||
|
||||
console.log(`> save blob as ${covidPassFilename}`);
|
||||
await incrementCount();
|
||||
|
||||
saveAs(passBlob, covidPassFilename);
|
||||
setSaveLoading(false);
|
||||
}
|
||||
|
@ -325,30 +323,40 @@ function Form(): JSX.Element {
|
|||
try {
|
||||
|
||||
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));
|
||||
let photoBlob: Blob;
|
||||
let filenameDetails = '';
|
||||
if (payloadBody.rawData.length > 0) {
|
||||
// This is an SHC receipt, so do our SHC thing
|
||||
selectedReceipt = payloadBody.shcReceipt;
|
||||
photoBlob = await Photo.generateSHCPass(payloadBody);
|
||||
filenameDetails = selectedReceipt.cardOrigin.replace(' ', '-');
|
||||
} else {
|
||||
// This is an old-style ON custom QR code Receipt
|
||||
selectedReceipt = payloadBody.receipts[selectedDose];
|
||||
const vaxName = selectedReceipt.vaccineName.replace(' ', '-');
|
||||
const passDose = selectedReceipt.numDoses;
|
||||
photoBlob = await Photo.generatePass(payloadBody, passDose);
|
||||
filenameDetails = `${vaxName}-${passDose}`;
|
||||
}
|
||||
const passName = selectedReceipt.name.replace(' ', '-');
|
||||
const vaxName = selectedReceipt.vaccineName.replace(' ', '-');
|
||||
const passDose = selectedReceipt.numDoses;
|
||||
const covidPassFilename = `grassroots-receipt-${passName}-${vaxName}-${passDose}.png`;
|
||||
const covidPassFilename = `grassroots-receipt-${passName}-${filenameDetails}.png`;
|
||||
|
||||
await incrementCount();
|
||||
|
||||
let photoBlob = await Photo.generatePass(payloadBody, passDose);
|
||||
saveAs(photoBlob, covidPassFilename);
|
||||
|
||||
// need to clean up
|
||||
const qrcodeElement = document.getElementById('qrcode');
|
||||
const svg = qrcodeElement.firstChild;
|
||||
qrcodeElement.removeChild(svg);
|
||||
const body = document.getElementById('pass-image');
|
||||
body.hidden = true;
|
||||
if (document.getElementById('qrcode').hasChildNodes()) {
|
||||
document.getElementById('qrcode').firstChild.remove();
|
||||
}
|
||||
|
||||
if (document.getElementById('shc-qrcode').hasChildNodes()) {
|
||||
document.getElementById('shc-qrcode').firstChild.remove();
|
||||
}
|
||||
|
||||
// Hide both our possible passes
|
||||
document.getElementById('pass-image').hidden = true;
|
||||
document.getElementById('shc-pass-image').hidden = true;
|
||||
|
||||
setSaveLoading(false);
|
||||
} catch (e) {
|
||||
|
@ -448,7 +456,7 @@ function Form(): JSX.Element {
|
|||
|
||||
<input type='file'
|
||||
id='file'
|
||||
accept="application/pdf"
|
||||
accept="application/pdf,.png,.jpg,.jpeg,.gif,.webp"
|
||||
ref={inputFile}
|
||||
style={{display: 'none'}}
|
||||
/>
|
||||
|
|
|
@ -102,7 +102,28 @@ function Page(props: PageProps): JSX.Element {
|
|||
<br/>
|
||||
<br/>
|
||||
</div>
|
||||
<canvas id="canvas" />
|
||||
<div id="shc-pass-image" style={{backgroundColor: "white", color: "black", fontFamily: 'Arial', fontSize: 10, width: '350px', padding: '10px'}} hidden>
|
||||
<table style={{verticalAlign: "middle"}}>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><img src='shield-black.svg' width='50' height='50' /></td>
|
||||
<td style={{fontSize: 20, width: 280}}>
|
||||
<span style={{marginLeft: '11px', whiteSpace: 'nowrap'}}><b>COVID-19 Vaccination Card</b></span><br/>
|
||||
<span style={{marginLeft: '11px'}}><b id='shc-card-origin'></b></span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<br/>
|
||||
<br/>
|
||||
<div id='shc-card-name' style={{fontSize:20, textAlign: 'center'}}></div>
|
||||
<br/>
|
||||
<br/>
|
||||
<div id='shc-qrcode' style={{width:'63%', display:'block', marginLeft: 'auto', marginRight: 'auto'}}></div>
|
||||
<br/>
|
||||
<br/>
|
||||
</div>
|
||||
<canvas id="canvas" style={{display: 'none'}}/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,97 @@
|
|||
{
|
||||
"iss": "https://smarthealthcard.phsa.ca/v1/issuer",
|
||||
"nbf": 1630861482,
|
||||
"vc": {
|
||||
"type": [
|
||||
"https://smarthealth.cards#covid19",
|
||||
"https://smarthealth.cards#immunization",
|
||||
"https://smarthealth.cards#health-card"
|
||||
],
|
||||
"credentialSubject": {
|
||||
"fhirVersion": "4.0.1",
|
||||
"fhirBundle": {
|
||||
"resourceType": "Bundle",
|
||||
"type": "collection",
|
||||
"entry": [
|
||||
{
|
||||
"fullUrl": "resource:0",
|
||||
"resource": {
|
||||
"resourceType": "Patient",
|
||||
"name": [
|
||||
{
|
||||
"family": "LASTNAME",
|
||||
"given": [
|
||||
"FIRSTNAME"
|
||||
]
|
||||
}
|
||||
],
|
||||
"birthDate": "YYYY-MM-DD"
|
||||
}
|
||||
},
|
||||
{
|
||||
"fullUrl": "resource:1",
|
||||
"resource": {
|
||||
"resourceType": "Immunization",
|
||||
"status": "completed",
|
||||
"vaccineCode": {
|
||||
"coding": [
|
||||
{
|
||||
"system": "http://hl7.org/fhir/sid/cvx",
|
||||
"code": "208"
|
||||
},
|
||||
{
|
||||
"system": "http://snomed.info/sct",
|
||||
"code": "28581000087106"
|
||||
}
|
||||
]
|
||||
},
|
||||
"patient": {
|
||||
"reference": "resource:0"
|
||||
},
|
||||
"occurrenceDateTime": "YYYY-MM-DD",
|
||||
"lotNumber": "SOME_STRING",
|
||||
"performer": [
|
||||
{
|
||||
"actor": {
|
||||
"display": "Pretty Location Display Name"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"fullUrl": "resource:2",
|
||||
"resource": {
|
||||
"resourceType": "Immunization",
|
||||
"status": "completed",
|
||||
"vaccineCode": {
|
||||
"coding": [
|
||||
{
|
||||
"system": "http://hl7.org/fhir/sid/cvx",
|
||||
"code": "208"
|
||||
},
|
||||
{
|
||||
"system": "http://snomed.info/sct",
|
||||
"code": "28581000087106"
|
||||
}
|
||||
]
|
||||
},
|
||||
"patient": {
|
||||
"reference": "resource:0"
|
||||
},
|
||||
"occurrenceDateTime": "YYYY-MM-DD",
|
||||
"lotNumber": "SOME_STRING",
|
||||
"performer": [
|
||||
{
|
||||
"actor": {
|
||||
"display": "Pretty Location Display Name"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,125 @@
|
|||
{
|
||||
"iss": "https://pvc.novascotia.ca/issuer",
|
||||
"nbf": 1633170858.747,
|
||||
"vc": {
|
||||
"type": [
|
||||
"https://smarthealth.cards#health-card",
|
||||
"https://smarthealth.cards#immunization",
|
||||
"https://smarthealth.cards#covid19"
|
||||
],
|
||||
"credentialSubject": {
|
||||
"fhirVersion": "4.0.1",
|
||||
"fhirBundle": {
|
||||
"resourceType": "Bundle",
|
||||
"type": "collection",
|
||||
"entry": [
|
||||
{
|
||||
"fullUrl": "resource:0",
|
||||
"resource": {
|
||||
"resourceType": "Patient",
|
||||
"name": [
|
||||
{
|
||||
"family": "LASTNAME",
|
||||
"given": [
|
||||
"FIRSTNAME"
|
||||
]
|
||||
}
|
||||
],
|
||||
"birthDate": "YYYY-MM-DD"
|
||||
}
|
||||
},
|
||||
{
|
||||
"fullUrl": "resource:1",
|
||||
"resource": {
|
||||
"resourceType": "Immunization",
|
||||
"meta": {
|
||||
"security": [
|
||||
{
|
||||
"system": "https://smarthealth.cards/ial",
|
||||
"code": "IAL1.4"
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": "completed",
|
||||
"vaccineCode": {
|
||||
"coding": [
|
||||
{
|
||||
"system": "http://hl7.org/fhir/sid/cvx",
|
||||
"code": "208"
|
||||
},
|
||||
{
|
||||
"system": "http://snomed.info/sct",
|
||||
"code": "28581000087106"
|
||||
}
|
||||
]
|
||||
},
|
||||
"patient": {
|
||||
"reference": "resource:0"
|
||||
},
|
||||
"occurrenceDateTime": "YYYY-MM-DD",
|
||||
"performer": [
|
||||
{
|
||||
"actor": {
|
||||
"display": "NS, Canada"
|
||||
}
|
||||
}
|
||||
],
|
||||
"lotNumber": "SOME_STRING",
|
||||
"manufacturer": {
|
||||
"identifier": {
|
||||
"system": "http://hl7.org/fhir/sid/mvx",
|
||||
"value": "PFR"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"fullUrl": "resource:2",
|
||||
"resource": {
|
||||
"resourceType": "Immunization",
|
||||
"meta": {
|
||||
"security": [
|
||||
{
|
||||
"system": "https://smarthealth.cards/ial",
|
||||
"code": "IAL1.4"
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": "completed",
|
||||
"vaccineCode": {
|
||||
"coding": [
|
||||
{
|
||||
"system": "http://hl7.org/fhir/sid/cvx",
|
||||
"code": "208"
|
||||
},
|
||||
{
|
||||
"system": "http://snomed.info/sct",
|
||||
"code": "28581000087106"
|
||||
}
|
||||
]
|
||||
},
|
||||
"patient": {
|
||||
"reference": "resource:0"
|
||||
},
|
||||
"occurrenceDateTime": "YYYY-MM-DD",
|
||||
"performer": [
|
||||
{
|
||||
"actor": {
|
||||
"display": "NS, Canada"
|
||||
}
|
||||
}
|
||||
],
|
||||
"lotNumber": "SOME_STRING",
|
||||
"manufacturer": {
|
||||
"identifier": {
|
||||
"system": "http://hl7.org/fhir/sid/mvx",
|
||||
"value": "PFR"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,79 @@
|
|||
{
|
||||
"iss": "https://covid19.quebec.ca/PreuveVaccinaleApi/issuer",
|
||||
"nbf": 1630525607,
|
||||
"vc": {
|
||||
"@context": [
|
||||
"https://www.w3.org/2018/credentials/v1"
|
||||
],
|
||||
"type": [
|
||||
"VerifiableCredential",
|
||||
"https://smarthealth.cards#health-card",
|
||||
"https://smarthealth.cards#covid19",
|
||||
"https://smarthealth.cards#immunization"
|
||||
],
|
||||
"credentialSubject": {
|
||||
"fhirVersion": "1.0.2",
|
||||
"fhirBundle": {
|
||||
"resourceType": "Bundle",
|
||||
"type": "Collection",
|
||||
"entry": [
|
||||
{
|
||||
"resource": {
|
||||
"resourceType": "Patient",
|
||||
"name": [
|
||||
{
|
||||
"family": "LASTNAME",
|
||||
"given": [
|
||||
"First Name"
|
||||
]
|
||||
}
|
||||
],
|
||||
"birthDate": "YYYY-MM-DD"
|
||||
}
|
||||
},
|
||||
{
|
||||
"resource": {
|
||||
"resourceType": "Immunization",
|
||||
"status": "Completed",
|
||||
"vaccineCode": {
|
||||
"coding": [
|
||||
{
|
||||
"system": "http://hl7.org/fhir/sid/cvx",
|
||||
"code": "208"
|
||||
}
|
||||
]
|
||||
},
|
||||
"patient": {
|
||||
"reference": "resource:0"
|
||||
},
|
||||
"occurrenceDateTime": "YYYY-MM-DD",
|
||||
"lotNumber": "SOME_STRING",
|
||||
"protocolApplied": {
|
||||
"doseNumber": 1,
|
||||
"targetDisease": {
|
||||
"coding": [
|
||||
{
|
||||
"system": "http://browser.ihtsdotools.org/?perspective=full&conceptId1=840536004",
|
||||
"code": "840536004"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"note": [
|
||||
{
|
||||
"text": "PB COVID-19"
|
||||
}
|
||||
],
|
||||
"reported": false,
|
||||
"wasNotGiven": false,
|
||||
"location": {
|
||||
"reference": "resource:0",
|
||||
"display": "LOCATION DISPLAY NAME"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,122 @@
|
|||
{
|
||||
"iss": "https://skphr.prd.telushealthspace.com",
|
||||
"nbf": 1631923594.5185204,
|
||||
"vc": {
|
||||
"type": [
|
||||
"VerifiableCredential",
|
||||
"https://smarthealth.cards#immunization",
|
||||
"https://smarthealth.cards#health-card",
|
||||
"https://smarthealth.cards#covid19"
|
||||
],
|
||||
"credentialSubject": {
|
||||
"fhirVersion": "4.0.1",
|
||||
"fhirBundle": {
|
||||
"resourceType": "Bundle",
|
||||
"id": "00000000-0000-0000-0000-000000000000",
|
||||
"type": "collection",
|
||||
"entry": [
|
||||
{
|
||||
"fullUrl": "resource:0",
|
||||
"resource": {
|
||||
"resourceType": "Patient",
|
||||
"name": [
|
||||
{
|
||||
"text": "LastName FirstName",
|
||||
"family": "FirstName",
|
||||
"given": [
|
||||
"LastName"
|
||||
]
|
||||
}
|
||||
],
|
||||
"birthDate": "YYYY-MM-DD"
|
||||
}
|
||||
},
|
||||
{
|
||||
"fullUrl": "resource:1",
|
||||
"resource": {
|
||||
"resourceType": "Immunization",
|
||||
"status": "completed",
|
||||
"vaccineCode": {
|
||||
"coding": [
|
||||
{
|
||||
"system": "http://hl7.org/fhir/sid/cvx",
|
||||
"code": "208"
|
||||
}
|
||||
]
|
||||
},
|
||||
"patient": {
|
||||
"reference": "resource:0"
|
||||
},
|
||||
"meta": {
|
||||
"security": [
|
||||
{
|
||||
"system": "https://smarthealth.cards/ial",
|
||||
"code": "IAL1.4"
|
||||
}
|
||||
]
|
||||
},
|
||||
"occurrenceDateTime": "YYYY-MM-DD",
|
||||
"performer": [
|
||||
{
|
||||
"actor": {
|
||||
"display": "Government of Saskatchewan"
|
||||
}
|
||||
}
|
||||
],
|
||||
"manufacturer": {
|
||||
"identifier": {
|
||||
"system": "http://fhir.ehealthsask.ca/organizationIdentifiers/EHS",
|
||||
"value": "Pfizer"
|
||||
}
|
||||
},
|
||||
"lotNumber": "SOME_STRING",
|
||||
"expirationDate": "YYYY-MM-DD"
|
||||
}
|
||||
},
|
||||
{
|
||||
"fullUrl": "resource:2",
|
||||
"resource": {
|
||||
"resourceType": "Immunization",
|
||||
"status": "completed",
|
||||
"vaccineCode": {
|
||||
"coding": [
|
||||
{
|
||||
"system": "http://hl7.org/fhir/sid/cvx",
|
||||
"code": "208"
|
||||
}
|
||||
]
|
||||
},
|
||||
"patient": {
|
||||
"reference": "resource:0"
|
||||
},
|
||||
"meta": {
|
||||
"security": [
|
||||
{
|
||||
"system": "https://smarthealth.cards/ial",
|
||||
"code": "IAL1.4"
|
||||
}
|
||||
]
|
||||
},
|
||||
"occurrenceDateTime": "YYYY-MM-DD",
|
||||
"performer": [
|
||||
{
|
||||
"actor": {
|
||||
"display": "Government of Saskatchewan"
|
||||
}
|
||||
}
|
||||
],
|
||||
"manufacturer": {
|
||||
"identifier": {
|
||||
"system": "http://fhir.ehealthsask.ca/organizationIdentifiers/EHS",
|
||||
"value": "Pfizer"
|
||||
}
|
||||
},
|
||||
"lotNumber": "SOME_STRING",
|
||||
"expirationDate": "YYYY-MM-DD"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,89 @@
|
|||
{
|
||||
"iss": "https://myvaccinerecord.cdph.ca.gov/creds",
|
||||
"nbf": 1628831358,
|
||||
"vc": {
|
||||
"type": [
|
||||
"https://smarthealth.cards#health-card",
|
||||
"https://smarthealth.cards#immunization",
|
||||
"https://smarthealth.cards#covid19"
|
||||
],
|
||||
"credentialSubject": {
|
||||
"fhirVersion": "4.0.1",
|
||||
"fhirBundle": {
|
||||
"resourceType": "Bundle",
|
||||
"type": "collection",
|
||||
"entry": [
|
||||
{
|
||||
"fullUrl": "resource:0",
|
||||
"resource": {
|
||||
"resourceType": "Patient",
|
||||
"name": [
|
||||
{
|
||||
"family": "Lastname",
|
||||
"given": [
|
||||
"First Name"
|
||||
]
|
||||
}
|
||||
],
|
||||
"birthDate": "YYYY-MM-DD"
|
||||
}
|
||||
},
|
||||
{
|
||||
"fullUrl": "resource:1",
|
||||
"resource": {
|
||||
"resourceType": "Immunization",
|
||||
"status": "completed",
|
||||
"vaccineCode": {
|
||||
"coding": [
|
||||
{
|
||||
"system": "http://hl7.org/fhir/sid/cvx",
|
||||
"code": "208"
|
||||
}
|
||||
]
|
||||
},
|
||||
"patient": {
|
||||
"reference": "resource:0"
|
||||
},
|
||||
"occurrenceDateTime": "YYYY-MM-DD",
|
||||
"lotNumber": "SOME_STRING",
|
||||
"performer": [
|
||||
{
|
||||
"actor": {
|
||||
"display": "Pretty Location Display Name"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"fullUrl": "resource:2",
|
||||
"resource": {
|
||||
"resourceType": "Immunization",
|
||||
"status": "completed",
|
||||
"vaccineCode": {
|
||||
"coding": [
|
||||
{
|
||||
"system": "http://hl7.org/fhir/sid/cvx",
|
||||
"code": "208"
|
||||
}
|
||||
]
|
||||
},
|
||||
"patient": {
|
||||
"reference": "resource:0"
|
||||
},
|
||||
"occurrenceDateTime": "YYYY-MM-DD",
|
||||
"lotNumber": "SOME_STRING",
|
||||
"performer": [
|
||||
{
|
||||
"actor": {
|
||||
"display": "Pretty Location Display Name"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,3 +1,6 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/types/global" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
|
|
|
@ -11,6 +11,7 @@
|
|||
"dependencies": {
|
||||
"@headlessui/react": "^1.3.0",
|
||||
"@ninja-labs/verify-pdf": "^0.3.9",
|
||||
"@nuintun/qrcode": "^3.0.1",
|
||||
"@sentry/browser": "^6.13.2",
|
||||
"@sentry/integrations": "^6.13.2",
|
||||
"@sentry/react": "^6.13.2",
|
||||
|
@ -308,6 +309,19 @@
|
|||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@nuintun/qrcode": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@nuintun/qrcode/-/qrcode-3.0.1.tgz",
|
||||
"integrity": "sha512-0dY347xYgJvmBP0I75UHeVEx7JeDczahhEYaS03uh9bhCqa3FycdtLPxWulQh/r3OsX5lpi+z3mEXfAaQtAPwQ==",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@nuintun/qrcode/node_modules/tslib": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
|
||||
"integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
|
||||
},
|
||||
"node_modules/@sentry/browser": {
|
||||
"version": "6.13.2",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-6.13.2.tgz",
|
||||
|
@ -5555,6 +5569,21 @@
|
|||
"fastq": "^1.6.0"
|
||||
}
|
||||
},
|
||||
"@nuintun/qrcode": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@nuintun/qrcode/-/qrcode-3.0.1.tgz",
|
||||
"integrity": "sha512-0dY347xYgJvmBP0I75UHeVEx7JeDczahhEYaS03uh9bhCqa3FycdtLPxWulQh/r3OsX5lpi+z3mEXfAaQtAPwQ==",
|
||||
"requires": {
|
||||
"tslib": "^2.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"tslib": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
|
||||
"integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"@sentry/browser": {
|
||||
"version": "6.13.2",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-6.13.2.tgz",
|
||||
|
|
|
@ -17,6 +17,7 @@
|
|||
"dependencies": {
|
||||
"@headlessui/react": "^1.3.0",
|
||||
"@ninja-labs/verify-pdf": "^0.3.9",
|
||||
"@nuintun/qrcode": "^3.0.1",
|
||||
"@sentry/browser": "^6.13.2",
|
||||
"@sentry/integrations": "^6.13.2",
|
||||
"@sentry/react": "^6.13.2",
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
nb
|
|
@ -0,0 +1,6 @@
|
|||
title: CovidPass
|
||||
subtitle: Legg til EUs digitale COVID -sertifikater i favorittlommebokappene dine.
|
||||
privacyPolicy: Privacy Policy
|
||||
donate: Sponsor
|
||||
gitHub: GitHub
|
||||
imprint: Avtrykk
|
|
@ -0,0 +1,20 @@
|
|||
noFileOrQrCode: Vennligst skann en QR-kode, eller velg en fil
|
||||
signatureFailed: Feil under signering av pass på server
|
||||
decodingFailed: Kunne ikke dekode nyttelast for QR-kode
|
||||
invalidColor: Ugyldig farge
|
||||
certificateData: Kunne ikke lese sertifikatdata
|
||||
nameMissing: Kunne ikke lese navnet
|
||||
dobMissing: Kunne ikke lese fødselsdatoen
|
||||
invalidMedicalProduct: Ugyldig medisinsk produkt
|
||||
invalidCountryCode: Ugyldig landskode
|
||||
invalidManufacturer: Ugyldig produsent
|
||||
invalidFileType: Ugyldig filtype
|
||||
couldNotDecode: Kunne ikke dekode QR-koden fra filen
|
||||
couldNotFindQrCode: Kunne ikke finne QR-koden i den medfølgende filen
|
||||
invalidQrCode: Ugyldig QR-kode
|
||||
certificateType: Fant ingen gyldig sertifikattype
|
||||
invalidTestResult: Ugyldig testresultat
|
||||
invalidTestType: Ugyldig testtype
|
||||
noCameraAccess: Fikk ikke tilgang til kameraet. Kontroller tillatelser under Innstillinger > Safari > Kamera.
|
||||
noCameraFound: Fant ikke kamera.
|
||||
safariSupportOnly: På iOS, vennligst bruk Safari nettleseren.
|
|
@ -0,0 +1,27 @@
|
|||
heading: Informasjon i henhold til § 5 TMG
|
||||
contact: Kontakt
|
||||
euDisputeResolution: EU Konfliktløsning
|
||||
euDisputeResolutionParagraph: |
|
||||
Europakommisjonen tilbyr en plattform for online konfliktløsning (OS) https://ec.europa.eu/consumers/odr.
|
||||
Du finner e-postadressen vår i avtrykket ovenfor.
|
||||
consumerDisputeResolution: Forbrukerkonfliktløsning
|
||||
consumerDisputeResolutionParagraph: Vi er ikke villige eller forpliktet til å delta i konfliktløsningssaker for et forbrukernemnd.
|
||||
liabilityForContents: Ansvar for innholdet
|
||||
liabilityForContentsParagraph: |
|
||||
Som tjenesteleverandør er vi ansvarlig for vårt eget innhold på disse sidene i henhold til § 7 avsnitt 1 TMG under de generelle lovene.
|
||||
I henhold til §§ 8 til 10 TMG er vi ikke forpliktet til å overvåke overført eller lagret informasjon eller å undersøke omstendigheter som indikerer ulovlig aktivitet.
|
||||
Forpliktelser til å fjerne eller blokkere bruk av informasjon i henhold til de generelle lovene forblir upåvirket.
|
||||
Ansvar i denne forbindelse er imidlertid bare mulig fra det tidspunkt et konkret brudd på loven blir kjent.
|
||||
Hvis vi blir klar over slike overtredelser, fjerner vi det relevante innholdet umiddelbart.
|
||||
liabilityForLinks: Ansvar for lenker
|
||||
liabilityForLinksParagraph: |
|
||||
Vårt tilbud inneholder lenker til eksterne nettsteder til tredjeparter, hvis innhold vi ikke har innflytelse på.
|
||||
Derfor kan vi ikke påta oss noe ansvar for dette eksterne innholdet.
|
||||
Den respektive leverandøren eller operatøren av nettstedene er alltid ansvarlig for innholdet på de koblede nettstedene.
|
||||
De koblede sidene ble sjekket for mulige lovbrudd på tidspunktet for koblingen.
|
||||
Ulovlig innhold var ikke gjenkjennelig på tidspunktet for koblingen.
|
||||
En permanent kontroll av innholdet på de lenkete sidene er imidlertid ikke rimelig uten konkrete bevis på brudd på loven.
|
||||
Hvis vi blir klar over brudd, vil vi fjerne slike lenker umiddelbart.
|
||||
credits: Kreditere
|
||||
creditsSource: Med utdrag fra https://www.e-recht24.de/impressum-generator.html
|
||||
creditsTranslation: Oversatt med https://www.DeepL.com/Translator (free version)
|
|
@ -0,0 +1,29 @@
|
|||
iosHint: På iOS, vennligst bruk Safari nettleseren.
|
||||
errorClose: Lukk
|
||||
selectCertificate: Velg Sertifikat
|
||||
selectCertificateDescription: |
|
||||
Skann QR-koden på sertifikatet ditt, eller velg et skjermbilde eller en PDF med QR-koden.
|
||||
Vær oppmerksom på at det ikke støttes å velge en fil direkte fra kameraet.
|
||||
stopCamera: Stopp Kamera
|
||||
startCamera: Start Kamera
|
||||
openFile: Velg Fil
|
||||
foundQrCode: Fant QR-kode!
|
||||
pickColor: Velg en farge
|
||||
pickColorDescription: Velg en bakgrunnsfarge for passet ditt.
|
||||
colorWhite: hvit
|
||||
colorBlack: svart
|
||||
colorGrey: grå
|
||||
colorGreen: grønn
|
||||
colorIndigo: mørkeblå
|
||||
colorBlue: blå
|
||||
colorPurple: lilla
|
||||
colorTeal: blågrønn
|
||||
addToWallet: Legg til i Lommebok
|
||||
dataPrivacyDescription: |
|
||||
Personvern er av spesiell betydning ved behandling av helserelaterte data.
|
||||
For å ta en informert beslutning, vennligst les
|
||||
iAcceptThe: Jeg godtar
|
||||
privacyPolicy: Personvernerklæring
|
||||
createdOnDevice: Laget på enheten din
|
||||
openSourceTransparent: Åpen kildekode og gjennomsiktig
|
||||
hostedInEU: Driftet i EU
|
|
@ -0,0 +1,57 @@
|
|||
gdprNotice: |
|
||||
Personvernerklæringen vår er basert på vilkårene som brukes av den europeiske lovgiveren
|
||||
for vedtakelsen av General Data Protection Regulation (GDPR).
|
||||
generalInfo: Generell informasjon
|
||||
generalInfoProcess: |
|
||||
Hele prosessen med å generere passfilen skjer lokalt i nettleseren din.
|
||||
For signeringstrinnet sendes bare en hash representasjon av dataene dine til serveren.
|
||||
generalInfoStoring: Dine data lagres ikke utover den aktive nettlesersessionen, og nettsten bruker ikke informasjonskapsler.
|
||||
generalInfoThirdParties: Ingen data blir sendt til tredjeparter.
|
||||
generalInfoHttps: Vi overfører dataene dine sikkert over https.
|
||||
generalInfoLocation: Serveren vår ligger i Nuremberg, Germany.
|
||||
generalInfoGitHub: Kildekoden til dette nettstedet er tilgjengelig på
|
||||
generalInfoLockScreen: Som standard er Apple Wallet pass tilgjengelig fra låseskjermen. Dette kan endres i
|
||||
settings: innstillinger
|
||||
generalInfoProvider: |
|
||||
Serverleverandøren behandler data for å tilby dette nettstedet.
|
||||
For å bedre forstå hvilke tiltak de tar for å beskytte dataene dine, vennligst les også deres
|
||||
privacyPolicy: personvernerklæring
|
||||
andThe: og
|
||||
dataPrivacyFaq: vanlige spørsmål om personvern
|
||||
contact: Kontakt
|
||||
email: Epost
|
||||
website: Webside
|
||||
process: Forenklet forklaring av prosessen
|
||||
processFirst: Først skjer følgende trinn lokalt i nettleseren din
|
||||
processSecond: Så skjer følgende trinn på serveren vår
|
||||
processThird: Til slutt skjer følgende trinn lokalt i nettleseren din
|
||||
processRecognizing: Gjenkjenne og trekke ut QR-kodedataene fra det valgte sertifikatet
|
||||
processDecoding: Dekoding av dine personlige og helserelaterte data fra QR-kode
|
||||
processAssembling: Montering av en ufullstendig pass-fil ut av dataene dine
|
||||
processGenerating: Generere en fil som inneholder hashes av dataene som er lagret i passfilen
|
||||
processSending: Sender bare filen som inneholder hasjene til serveren vår
|
||||
processReceiving: Motta og sjekke hashene som ble generert lokalt
|
||||
processSigning: Signerer filen som inneholder hasjene
|
||||
processSendingBack: Sender signaturen tilbake
|
||||
processCompleting: Montering av den signerte passfilen fra den ufullstendige filen generert lokalt og signaturen
|
||||
processSaving: Lagrer filen på enheten din
|
||||
locallyProcessedData: Lokalt behandlede data
|
||||
the:
|
||||
schema: Skjema for digitalt Covid -sertifikat
|
||||
specification: inneholder en detaljert spesifikasjon av hvilke data som kan finnes i QR -koden og vil bli behandlet i nettleseren din.
|
||||
serverProvider: Serverleverandør
|
||||
serverProviderIs: Serverleverandøren vår er
|
||||
logFiles: Følgende data kan samles inn og lagres i serverloggfilene
|
||||
logFilesBrowser: Nettlesertypene og versjonene som brukes
|
||||
logFilesOs: Operativsystemet som brukes av tilgangssystemet
|
||||
logFilesReferrer: Nettstedet som et tilgangssystem kommer til nettstedet vårt fra (såkalte henvisninger)
|
||||
logFilesTime: Dato og klokkeslett for tilgang
|
||||
logFilesIpAddress: De pseudonymiserte IP-adressene
|
||||
rights: Dine rettigheter
|
||||
rightsGranted: I samsvar med GDPR har du følgende rettigheter
|
||||
rightsAccess: Rett til tilgang til dataene dine; Du har rett til å vite hvilke data som er samlet om deg og hvordan de ble behandlet.
|
||||
rightsErasure: Rett til å bli glemt; Sletting av dine personlige data.
|
||||
rightsRectification: Rett til å rette opp; Du har rett til å korrigere unøyaktige data.
|
||||
rightsPortability: Rett til dataportabilitet; Du har rett til å overføre dataene dine fra et behandlingssystem til et annet.
|
||||
thirdParties: Tredjeparter knyttet til
|
||||
appleSync: Apple kan synkronisere passene dine via iCloud
|
|
@ -0,0 +1 @@
|
|||
nb
|
|
@ -0,0 +1 @@
|
|||
no
|
|
@ -0,0 +1,13 @@
|
|||
<svg width="111.811" height="122.88" xmlns="http://www.w3.org/2000/svg">
|
||||
|
||||
<g>
|
||||
<title>background</title>
|
||||
<rect fill="none" id="canvas_background" height="514" width="514" y="-1" x="-1"/>
|
||||
</g>
|
||||
<g>
|
||||
<title>Layer 1</title>
|
||||
<g id="svg_1">
|
||||
<path fill="#000000" id="svg_2" d="m55.713,0c20.848,13.215 39.682,19.467 55.846,17.989c2.823,57.098 -18.263,90.818 -55.63,104.891c-36.085,-13.172 -57.429,-45.441 -55.846,-105.757c18.975,0.993 37.591,-3.109 55.63,-17.123l0,0zm-21.929,66.775c-1.18,-1.01 -1.318,-2.786 -0.309,-3.967c1.011,-1.181 2.787,-1.318 3.967,-0.309l11.494,9.875l25.18,-27.684c1.047,-1.15 2.828,-1.234 3.979,-0.188c1.149,1.046 1.233,2.827 0.187,3.978l-27.02,29.708l-0.002,-0.002c-1.02,1.121 -2.751,1.236 -3.91,0.244l-13.566,-11.655l0,0zm21.951,-59.72c18.454,11.697 35.126,17.232 49.434,15.923c2.498,50.541 -16.166,80.39 -49.241,92.846c-31.942,-11.659 -50.837,-40.221 -49.435,-93.613c16.797,0.88 33.275,-2.751 49.242,-15.156l0,0z" clip-rule="evenodd" fill-rule="evenodd"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 1007 B |
191
src/decode.ts
191
src/decode.ts
|
@ -1,10 +1,11 @@
|
|||
// adapted from https://github.com/fproulx/shc-covid19-decoder/blob/main/src/shc.js
|
||||
|
||||
const jsQR = require("jsqr");
|
||||
const zlib = require("zlib");
|
||||
import {Receipt, HashTable} from "./payload";
|
||||
import {SHCReceipt, SHCVaccinationRecord} from "./payload";
|
||||
import {getVerifiedIssuer} from "./issuers";
|
||||
import {registerPass, generateSHCRegisterPayload} from "./passphoto-common";
|
||||
|
||||
export function getQRFromImage(imageData) {
|
||||
export function getQRFromImage(imageData: ImageData) {
|
||||
return jsQR(
|
||||
new Uint8ClampedArray(imageData.data.buffer),
|
||||
imageData.width,
|
||||
|
@ -16,92 +17,142 @@ export function getQRFromImage(imageData) {
|
|||
// https://gist.github.com/alexdunae/49cc0ea95001da3360ad6896fa5677ec
|
||||
// http://mchp-appserv.cpe.umanitoba.ca/viewConcept.php?printer=Y&conceptID=1514
|
||||
|
||||
// .vc.credentialSubject.fhirBundle.entry
|
||||
export function decodedStringToReceipt(decoded: object) : HashTable<Receipt> {
|
||||
|
||||
const codeToVaccineName = {
|
||||
'28581000087106': 'PFIZER',
|
||||
'28951000087107': 'JANSSEN',
|
||||
'28761000087108': 'ASTRAZENECA',
|
||||
'28571000087109': 'MODERNA'
|
||||
function getCvxVaccineCodeForResource(immunizationResource: any) : string {
|
||||
for (let curCoding of immunizationResource.vaccineCode.coding) {
|
||||
if (curCoding.system === 'http://hl7.org/fhir/sid/cvx') {
|
||||
return curCoding.code;
|
||||
}
|
||||
}
|
||||
|
||||
console.error(`Cannot determine vaccine type - missing expected coding [http://hl7.org/fhir/sid/cvx] in SHC resource [${JSON.stringify(immunizationResource)}]`);
|
||||
return null;
|
||||
}
|
||||
|
||||
function getOrganizationForResource(immunizationResource: any) : string {
|
||||
// By default, this is under performer actor display
|
||||
if (immunizationResource.performer) {
|
||||
// Assume there's only one performer (this is all that has been seen in data to date)
|
||||
return immunizationResource.performer[0].actor.display;
|
||||
}
|
||||
|
||||
// Quebec does something different from most.
|
||||
if (immunizationResource.location) {
|
||||
return immunizationResource.location.display;
|
||||
}
|
||||
|
||||
console.error(`Cannot determine organization name for SHC resource [${JSON.stringify(immunizationResource)}]`);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function decodedStringToReceipt(decoded: object) : SHCReceipt {
|
||||
|
||||
const cvxCodeToVaccineName = { // https://www2a.cdc.gov/vaccines/iis/iisstandards/vaccines.asp?rpt=cvx
|
||||
'208': 'PFIZER',
|
||||
'208': 'PFIZER', // Nominally for 16+, in practice seems to be used for 12+
|
||||
// The two records below are for childrens' doses of Pfizer, they have different CVX codes.
|
||||
'217': 'PFIZER', // 12+ dose size, unclear how this differs from 208
|
||||
'218': 'PFIZER', // 5-11 dose size
|
||||
'219': 'PFIZER', // 2-4 dose size
|
||||
'212': 'JANSSEN',
|
||||
'210': 'ASTRAZENECA',
|
||||
'207': 'MODERNA'
|
||||
'207': 'MODERNA',
|
||||
'510': 'SINOPHARM-BIBP',
|
||||
'511': 'CORONAVAC-SINOVAC',
|
||||
// All of the vaccines below this line have not yet been approved by the WHO, but they are here because we have seen them all in our dataset
|
||||
'502': 'COVAXIN',
|
||||
'505': 'SPUTNIK V',
|
||||
'506': 'CONVIDECIA',
|
||||
'507': 'ZIFIVAX',
|
||||
// All of the vaccines below are listed as CVX codes, but we haven't seen them yet in our data - adding for completeness
|
||||
'501': 'QAZCOVID-IN',
|
||||
'503': 'COVIVAC',
|
||||
'500': 'UNKNOWN',
|
||||
'213': 'UNKNOWN',
|
||||
'509': 'EPIVACCORONA',
|
||||
'508': 'CHOCELL',
|
||||
'211': 'NOVAVAX',
|
||||
'504': 'SPUTNIK LIGHT',
|
||||
}
|
||||
|
||||
// Track whether the SHC code is validated - if it is not, we will record it so that we can
|
||||
// proactively track new SHC codes we haven't seen yet and support them quickly if appropriate
|
||||
let isValidatedSHC = true;
|
||||
|
||||
// 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';
|
||||
const verifiedIssuer = getVerifiedIssuer(decoded['iss']);
|
||||
|
||||
if (!verifiedIssuer) {
|
||||
// Bail out now - this is not a recognized issuer
|
||||
console.error(`Issuer ${decoded['iss']} was not a recognized issuer! Cannot create card`);
|
||||
isValidatedSHC = false;
|
||||
}
|
||||
|
||||
// Now verify that this SHC deals with a COVID immunization
|
||||
const cardData = decoded['vc'];
|
||||
const isCovidCard = cardData.type.includes('https://smarthealth.cards#covid19');
|
||||
if (!isCovidCard) {
|
||||
// Bail out now - this is not a COVID card
|
||||
console.error(`SHC QR code was not COVID-related (type [https://smarthealth.cards#covid19] not found)! Cannot create card`);
|
||||
isValidatedSHC = false;
|
||||
}
|
||||
|
||||
// If we're here, we have an SHC QR code which was issued by a recognized issuer. Start mapping values now
|
||||
|
||||
const shcResources = cardData.credentialSubject.fhirBundle.entry;
|
||||
let name = '';
|
||||
let dateOfBirth;
|
||||
let receipts : HashTable<Receipt> = {};
|
||||
let dateOfBirth = '';
|
||||
const vaxRecords: SHCVaccinationRecord[] = [];
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
switch(resource.resourceType) {
|
||||
case 'Patient':
|
||||
// Assume there is only one name on the card.
|
||||
const nameObj = resource.name[0];
|
||||
name = `${nameObj.given.join(' ')} ${nameObj.family}`;
|
||||
dateOfBirth = resource.birthDate;
|
||||
console.log('Detected Patient resource, added name and birthdate');
|
||||
|
||||
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;
|
||||
break;
|
||||
|
||||
case 'Immunization':
|
||||
// Start collecting data about this immunization record
|
||||
const vaccineCode = getCvxVaccineCodeForResource(resource);
|
||||
const vaccineName = cvxCodeToVaccineName[vaccineCode];
|
||||
const organizationName = getOrganizationForResource(resource);
|
||||
const vaccinationDate = resource.occurrenceDateTime;
|
||||
|
||||
const receipt = new Receipt(name, vaccinationDate, vaccineName, dateOfBirth, receiptNumber, organizationName);
|
||||
// console.log(receipt);
|
||||
receipts[receiptNumber] = receipt;
|
||||
// Add this to our set of records
|
||||
vaxRecords.push(new SHCVaccinationRecord(vaccineName, vaccinationDate, organizationName));
|
||||
console.log(`Detected Immunization resource, added vaccination record (current count: ${vaxRecords.length})`);
|
||||
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn(`Unexpected SHC resource type ${resource.resourceType}! Ignoring...`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return receipts;
|
||||
|
||||
if (name.length === 0 || dateOfBirth.length === 0) {
|
||||
// Bail out now - we are missing basic info
|
||||
console.error(`No name or birthdate was found! Cannot create card`);
|
||||
isValidatedSHC = false;
|
||||
}
|
||||
|
||||
const retReceipt = new SHCReceipt(name, dateOfBirth, verifiedIssuer.display, verifiedIssuer.iss, vaxRecords);
|
||||
console.log(`Creating receipt for region [${retReceipt.cardOrigin}] with vaccination records [${JSON.stringify(retReceipt.vaccinations)}]`);
|
||||
|
||||
if (!isValidatedSHC) {
|
||||
// Send this SHC to our registration endpoint so we can proactively track and react to unexpected SHCs
|
||||
// (e.g. for jurisdictions we aren't aware of yet)
|
||||
const registerPayload = generateSHCRegisterPayload(retReceipt);
|
||||
registerPass(registerPayload);
|
||||
|
||||
// Now bail out
|
||||
return null;
|
||||
}
|
||||
|
||||
return retReceipt;
|
||||
}
|
|
@ -1,16 +1,22 @@
|
|||
const issuers = [
|
||||
export const issuers = [
|
||||
{
|
||||
id: "ca.qc",
|
||||
display: "Québec",
|
||||
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" },
|
||||
{ kid: "2XlWk1UQMqavMtLt-aX35q_q9snFtGgdjH4-Y1gfH1M",
|
||||
alg: "ES256", kty: "EC", crv: "P-256", use: "sig",
|
||||
x: "XSxuwW_VI_s6lAw6LAlL8N7REGzQd_zXeIVDHP_j_Do",
|
||||
y: "88-aI4WAEl4YmUpew40a9vq_w5OcFvsuaKMxJRLRLL0" },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "us.ca",
|
||||
display: "California, USA",
|
||||
iss: "https://myvaccinerecord.cdph.ca.gov/creds",
|
||||
keys: [
|
||||
{ kid: "7JvktUpf1_9NPwdM-70FJT3YdyTiSe2IvmVxxgDSRb0",
|
||||
|
@ -21,6 +27,7 @@ const issuers = [
|
|||
},
|
||||
{
|
||||
id: "us.ny",
|
||||
display: "New York, USA",
|
||||
iss: "https://ekeys.ny.gov/epass/doh/dvc/2021",
|
||||
keys: [
|
||||
{ kid: "9ENs36Gsu-GmkWIyIH9XCozU9BFhLeaXvwrT3B97Wok",
|
||||
|
@ -31,6 +38,7 @@ const issuers = [
|
|||
},
|
||||
{
|
||||
id: "us.la",
|
||||
display: "Louisiana, USA",
|
||||
iss: "https://healthcardcert.lawallet.com",
|
||||
keys: [
|
||||
{ kid: "UOvXbgzZj4zL-lt1uJVS_98NHQrQz48FTdqQyNEdaNE",
|
||||
|
@ -41,16 +49,23 @@ const issuers = [
|
|||
},
|
||||
{
|
||||
id: "ca.yt",
|
||||
display: "Yukon",
|
||||
iss: "https://pvc.service.yukon.ca/issuer",
|
||||
keys: [
|
||||
{ kid: "UnHGY-iyCIr__dzyqcxUiApMwU9lfeXnzT2i5Eo7TvE",
|
||||
alg: "ES256", kty: "EC", crv: "P-256", use: "sig",
|
||||
x5c: [
|
||||
"MIICGTCCAZ6gAwIBAgIJALC8NylJvTNbMAoGCCqGSM49BAMDMDMxMTAvBgNVBAMMKEdvdmVybm1lbnQgb2YgWXVrb24gU01BUlQgSGVhbHRoIENhcmQgQ0EwHhcNMjEwODI4MjM1NzQyWhcNMjIwODI4MjM1NzQyWjA3MTUwMwYDVQQDDCxHb3Zlcm5tZW50IG9mIFl1a29uIFNNQVJUIEhlYWx0aCBDYXJkIElzc3VlcjBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABMAnk/a3S2E6TiudjitPqEW8A8W5YyXTYg7sT7D7f9YKIgFPi1OrHtJWJGwPMvlueeHmULUKEpScgpQtoHNjX+SjgZYwgZMwCQYDVR0TBAIwADALBgNVHQ8EBAMCB4AwOQYDVR0RBDIwMIYuaHR0cHM6Ly9zcGVjLnNtYXJ0aGVhbHRoLmNhcmRzL2V4YW1wbGVzL2lzc3VlcjAdBgNVHQ4EFgQUitSA1n/iAP1N2FYJwvvM624tgaUwHwYDVR0jBBgwFoAU5wzUU8M7Lqq4yxgxB2Yfc8neS6UwCgYIKoZIzj0EAwMDaQAwZgIxAMznKWBgcaCywPLb2/XxRaG6rnrcF5Si3JXAxi9z9PLapjjFXnn01PihQ8uf6jGM1AIxAK2ySU71gTqXbriCMq0ALOcLW0zmzcaLLEAmq5kR6iunRZNFp1v4MQxLUno5qsm2Rg==",
|
||||
"MIICEzCCAXWgAwIBAgIJALQZZWjfw5NnMAoGCCqGSM49BAMEMDgxNjA0BgNVBAMMLUdvdmVybm1lbnQgb2YgWXVrb24gU01BUlQgSGVhbHRoIENhcmQgUm9vdCBDQTAeFw0yMTA4MjQyMTM0MDRaFw0yNjA4MjMyMTM0MDRaMDMxMTAvBgNVBAMMKEdvdmVybm1lbnQgb2YgWXVrb24gU01BUlQgSGVhbHRoIENhcmQgQ0EwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAS3TOjxd26WFX4RPaTnWcpw7/ZsfBb/+s/I7Gt+9GAsmZKjvZUDOf9Dc1ARkPh76BaE+ABzZ83LcmDqMhYdHbZXB6hOvVTmqYjtnQIovFK8irY1MsmWPfq4BW7QN6B6aKCjUDBOMAwGA1UdEwQFMAMBAf8wHQYDVR0OBBYEFOcM1FPDOy6quMsYMQdmH3PJ3kulMB8GA1UdIwQYMBaAFAbK320153UDRVQ30TnWECrILX8KMAoGCCqGSM49BAMEA4GLADCBhwJCAPvYBOO/NVZaglYiW35+z8UvhEadsCWfAknIdJBYkKdNQWQ0ktqS7+ctqBEVCUQng2IROSG4BnJrs7H7+1G4wgZnAkEQ+HuRcVUmiicTkPDlbwZHtoDEc1fv3TvNG9h+Qp5WvfLivz7BrFHt11ByuTCxEn7juv3B1JhpyPVZKTnQ+Nefpw==",
|
||||
"MIICPTCCAaCgAwIBAgIJAOuKKbelzgyxMAoGCCqGSM49BAMEMDgxNjA0BgNVBAMMLUdvdmVybm1lbnQgb2YgWXVrb24gU01BUlQgSGVhbHRoIENhcmQgUm9vdCBDQTAeFw0yMTA4MjQyMTM0MDRaFw0zMTA4MjIyMTM0MDRaMDgxNjA0BgNVBAMMLUdvdmVybm1lbnQgb2YgWXVrb24gU01BUlQgSGVhbHRoIENhcmQgUm9vdCBDQTCBmzAQBgcqhkjOPQIBBgUrgQQAIwOBhgAEAIQ94p9+W9Iszm7Nizv/NHnLKGM624IEdsJUs9MrTjCBNUsuRJNa+F99tTjTqP5u1ocbGVxSrfRxlc8Fv1fGROi5AB2c4iA+fOT55iX1TQbguCsv0YobG2YOCwHPIwcFfUm4bxTLnLjky5i7wYWPMlwj+JFmzuUkaPxFY8pdZnyMxoaSo1AwTjAMBgNVHRMEBTADAQH/MB0GA1UdDgQWBBQGyt9tNed1A0VUN9E51hAqyC1/CjAfBgNVHSMEGDAWgBQGyt9tNed1A0VUN9E51hAqyC1/CjAKBggqhkjOPQQDBAOBigAwgYYCQVSvLDHcrHXqLntgB4rXgMr7lgZ/wVBqYlzhrD8SXNNvGM8lu9KV3sWHV7n3eyyqnOR/etyb52zoKe6NBkBG+AsbAkFaxXyuwweeZHAJ379aIlKJJ2EySEOY9Bo6j+6DYd69V+lFQtNQithatUS06xLXgcXD8TF6ZcoALiP3oOwF17xHDA=="
|
||||
],
|
||||
x: "wCeT9rdLYTpOK52OK0-oRbwDxbljJdNiDuxPsPt_1go",
|
||||
y: "IgFPi1OrHtJWJGwPMvlueeHmULUKEpScgpQtoHNjX-Q" },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "ca.bc",
|
||||
display: "British Columbia",
|
||||
iss: "https://smarthealthcard.phsa.ca/v1/issuer",
|
||||
keys: [
|
||||
{ kid: "XCqxdhhS7SWlPqihaUXovM_FjU65WeoBFGc_ppent0Q",
|
||||
|
@ -61,6 +76,7 @@ const issuers = [
|
|||
},
|
||||
{
|
||||
id: "ca.sk",
|
||||
display: "Saskatchewan",
|
||||
iss: "https://skphr.prd.telushealthspace.com",
|
||||
keys: [
|
||||
{ kid: "xOqUO82bEz8APn_5wohZZvSK4Ui6pqWdSAv5BEhkes0",
|
||||
|
@ -71,6 +87,7 @@ const issuers = [
|
|||
},
|
||||
{
|
||||
id: "ca.ab",
|
||||
display: "Alberta",
|
||||
iss: "https://covidrecords.alberta.ca/smarthealth/issuer",
|
||||
keys: [
|
||||
{ kid: "JoO-sJHpheZboXdsUK4NtfulfvpiN1GlTdNnXN3XAnM",
|
||||
|
@ -78,9 +95,39 @@ const issuers = [
|
|||
x: "GsriV0gunQpl2X9KgrDZ4EDCtIdfOmdzhdlosWrMqKk",
|
||||
y: "S99mZMCcJRsn662RaAmk_elvGiUs8IvSA7qBh04kaw0" },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "ca.ns",
|
||||
display: "Nova Scotia",
|
||||
iss: "https://pvc.novascotia.ca/issuer",
|
||||
keys: [
|
||||
{ kid: "UJrT9jU8vOCUl4xsI1RZjOPP8hFUv7n9mhVtolqH9qw",
|
||||
alg: "ES256", kty: "EC", crv: "P-256", use: "sig",
|
||||
x: "kIaIeOhhxpiN13sDs6RKVzCpvxxObI9adKF5YEmKngM",
|
||||
y: "AZPQ7CHd3UHp0i4a4ua1FhIq8SJ__BuHgDESuK3A_zQ" },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "ca.on",
|
||||
display: "Ontario",
|
||||
iss: "https://prd.pkey.dhdp.ontariohealth.ca",
|
||||
keys: [
|
||||
{ kid: "Nlgwb6GUrU_f0agdYKc77sXM9U8en1gBu94plufPUj8",
|
||||
alg: "ES256", kty: "EC", crv: "P-256", use: "sig",
|
||||
x: "ibapbMkHMlkR3D-AU0VTFDsiidQ49oD9Ha7VY8Gao3s",
|
||||
y: "arXU5frZGOvTZpvg045rHC7y0fqVOS3dKqJbUYhW5gw" },
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
module.exports = {
|
||||
issuers,
|
||||
};
|
||||
// Check for updates above at https://files.ontario.ca/apps/verify/verifyRulesetON.json
|
||||
|
||||
export function getVerifiedIssuer(requestIssuer) {
|
||||
for (let curIssuer of issuers) {
|
||||
if (curIssuer.iss === requestIssuer) {
|
||||
return curIssuer;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
25
src/pass.ts
25
src/pass.ts
|
@ -1,11 +1,9 @@
|
|||
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';
|
||||
import {QrCode,Encoding,PackageResult,QrFormat,PassPhotoCommon} from './passphoto-common';
|
||||
import {QrCode,PassPhotoCommon} from './passphoto-common';
|
||||
import {COLORS} from "./colors";
|
||||
|
||||
const crypto = require('crypto')
|
||||
|
||||
|
@ -48,7 +46,7 @@ export class PassData {
|
|||
const apiBaseUrl = (await configResponse.json()).apiBaseUrl
|
||||
// console.log(`${apiBaseUrl}/sign`);
|
||||
|
||||
// console.log(JSON.stringify(signData));
|
||||
console.log(JSON.stringify(signData));
|
||||
|
||||
const response = await fetch(`${apiBaseUrl}/sign`, {
|
||||
method: 'POST',
|
||||
|
@ -77,6 +75,10 @@ export class PassData {
|
|||
|
||||
const pass: PassData = new PassData(results.payload, results.qrCode);
|
||||
|
||||
if (!pass.expirationDate) {
|
||||
delete pass['expirationDate'];
|
||||
}
|
||||
|
||||
// Create new zip
|
||||
const zip = [] as { path: string; data: Buffer | string }[];
|
||||
|
||||
|
@ -116,10 +118,12 @@ export class PassData {
|
|||
// Create pass hash
|
||||
const passHash = PassData.getBufferHash(Buffer.from(passJson));
|
||||
|
||||
const useBlackVersion = (payload.backgroundColor == COLORS.WHITE);
|
||||
|
||||
// Sign hash with server
|
||||
const manifestSignature = await PassData.signWithRemote({
|
||||
PassJsonHash: passHash,
|
||||
useBlackVersion: false,
|
||||
useBlackVersion: useBlackVersion,
|
||||
});
|
||||
|
||||
// Add signature to zip
|
||||
|
@ -139,7 +143,14 @@ export class PassData {
|
|||
this.barcodes = [qrCode];
|
||||
this.barcode = qrCode;
|
||||
this.generic = payload.generic;
|
||||
this.sharingProhibited = true;
|
||||
this.expirationDate = payload.expirationDate;
|
||||
|
||||
// Update our pass name if this is an SHC pass
|
||||
if (payload.rawData.length > 0) {
|
||||
const newPassTitle = `${Constants.NAME}, ${payload.shcReceipt.cardOrigin}`;
|
||||
this.logoText = newPassTitle;
|
||||
this.organizationName = newPassTitle;
|
||||
this.description = newPassTitle;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,10 +1,6 @@
|
|||
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';
|
||||
import {Payload, PayloadBody, SHCReceipt} from "./payload";
|
||||
|
||||
export enum QrFormat {
|
||||
PKBarcodeFormatQR = 'PKBarcodeFormatQR',
|
||||
|
@ -28,72 +24,114 @@ export interface PackageResult {
|
|||
qrCode: QrCode;
|
||||
}
|
||||
|
||||
var _configData: any;
|
||||
async function getConfigData(): Promise<any> {
|
||||
if (!_configData) {
|
||||
// Only call this once
|
||||
const configResponse = await fetch('/api/config');
|
||||
_configData = await configResponse.json();
|
||||
}
|
||||
|
||||
return _configData;
|
||||
}
|
||||
|
||||
export async function registerPass(registrationPayload: any) : Promise<boolean> {
|
||||
const requestOptions = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(registrationPayload) // body data type must match "Content-Type" header
|
||||
}
|
||||
|
||||
const configData = await getConfigData();
|
||||
// console.log('registering ' + JSON.stringify(registrationPayload, null, 2));
|
||||
|
||||
const registrationHost = configData.registrationHost;
|
||||
const functionSuffix = configData.functionSuffix?? '';
|
||||
|
||||
const registerUrl = `${registrationHost}/register${functionSuffix}`;
|
||||
// console.log(registerUrl);
|
||||
|
||||
try {
|
||||
const response = await fetch(registerUrl, requestOptions);
|
||||
const responseJson = await response.json();
|
||||
|
||||
// console.log(JSON.stringify(responseJson,null,2));
|
||||
const wasSuccess = (responseJson["result"] === 'OK');
|
||||
if (!wasSuccess) {
|
||||
console.error(responseJson);
|
||||
}
|
||||
|
||||
return Promise.resolve(wasSuccess);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
Sentry.captureException(e);
|
||||
|
||||
// Registration was unsuccessful
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function generateSHCRegisterPayload(shcReceipt: SHCReceipt) {
|
||||
const retPayload = {};
|
||||
retPayload['cardOrigin'] = shcReceipt.cardOrigin;
|
||||
retPayload['issuer'] = shcReceipt.issuer;
|
||||
|
||||
for (let i = 0; i < shcReceipt.vaccinations.length; i++) {
|
||||
retPayload[`vaccination_${i}`] = shcReceipt.vaccinations[i];
|
||||
}
|
||||
|
||||
return retPayload;
|
||||
}
|
||||
|
||||
export class PassPhotoCommon {
|
||||
|
||||
static async preparePayload(payloadBody: PayloadBody, numDose: number) : Promise<PackageResult> {
|
||||
static async preparePayload(payloadBody: PayloadBody, numDose: number = 0) : Promise<PackageResult> {
|
||||
|
||||
console.log('preparePayload');
|
||||
|
||||
// console.log(JSON.stringify(payloadBody, null, 2), numDose);
|
||||
|
||||
const configData = await getConfigData();
|
||||
const payload: Payload = new Payload(payloadBody, numDose);
|
||||
|
||||
payload.serialNumber = uuid4();
|
||||
let qrCodeMessage;
|
||||
let qrCodeMessage = '';
|
||||
let registrationPayload: any;
|
||||
|
||||
if (payloadBody.rawData.startsWith('shc:/')) {
|
||||
|
||||
qrCodeMessage = payloadBody.rawData;
|
||||
|
||||
// Register an SHC pass by adding in our pertinent data fields. We only do this so we
|
||||
// can detect changes from providers and react quickly - we don't need these for any
|
||||
// validation since SHCs are self-validating. This entire registration process could
|
||||
// be turned off for SHCs and there would be no harm to the card creation process
|
||||
registrationPayload = generateSHCRegisterPayload(payloadBody.shcReceipt);
|
||||
|
||||
} else {
|
||||
|
||||
// register record
|
||||
// register an old-style ON pass
|
||||
|
||||
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(`Error while trying to register pass!`);
|
||||
}
|
||||
registrationPayload = Object.assign({}, payloadBody.receipts[numDose]);
|
||||
delete registrationPayload.name;
|
||||
delete registrationPayload.dateOfBirth;
|
||||
registrationPayload["serialNumber"] = payload.serialNumber;
|
||||
registrationPayload["type"] = 'applewallet';
|
||||
|
||||
const verifierHost = configData.verifierHost;
|
||||
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;
|
||||
qrCodeMessage = `${verifierHost}/verify?${encodedUri}`;
|
||||
// console.log(qrCodeUrl);
|
||||
}
|
||||
|
||||
|
||||
const wasSuccess = await registerPass(registrationPayload);
|
||||
if (!wasSuccess) {
|
||||
return Promise.reject(`Error while trying to register pass!`);
|
||||
}
|
||||
|
||||
// Create QR Code Object
|
||||
const qrCode: QrCode = {
|
||||
message: qrCodeMessage,
|
||||
|
|
217
src/payload.ts
217
src/payload.ts
|
@ -1,17 +1,25 @@
|
|||
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) {};
|
||||
constructor(public name: string, public vaccinationDate: string, public vaccineName: string, public dateOfBirth: string, public numDoses: number, public organization: string) {};
|
||||
}
|
||||
|
||||
export interface HashTable<T> {
|
||||
[key: string]: T;
|
||||
}
|
||||
|
||||
enum TextAlignment {
|
||||
right = 'PKTextAlignmentRight',
|
||||
center = 'PKTextAlignmentCenter'
|
||||
// QR CODE NEW FORMAT:
|
||||
// * Origin jurisdiction
|
||||
// * Person's name
|
||||
// * NOTHING ELSE ON THE CARD TO ENCOURAGE SCANNING IT TO GET DATA (this is what QC does, and what BC mostly does; other jurisdictions add more data, but that encourages bad behaviour)
|
||||
|
||||
export class SHCReceipt {
|
||||
constructor(public name: string, public dateOfBirth: string, public cardOrigin: string, public issuer: string, public vaccinations: SHCVaccinationRecord[]) {};
|
||||
}
|
||||
|
||||
export class SHCVaccinationRecord {
|
||||
constructor(public vaccineName: string, public vaccinationDate: string, public organization: string) {};
|
||||
}
|
||||
|
||||
interface Field {
|
||||
|
@ -30,14 +38,15 @@ export interface PassDictionary {
|
|||
}
|
||||
|
||||
export interface PayloadBody {
|
||||
// color: COLORS;
|
||||
rawData: string;
|
||||
receipts: HashTable<Receipt>;
|
||||
shcReceipt: SHCReceipt;
|
||||
}
|
||||
|
||||
export class Payload {
|
||||
|
||||
receipts: HashTable<Receipt>;
|
||||
shcReceipt: SHCReceipt;
|
||||
rawData: string;
|
||||
backgroundColor: string;
|
||||
labelColor: string;
|
||||
|
@ -48,44 +57,41 @@ export class Payload {
|
|||
generic: PassDictionary;
|
||||
expirationDate: string;
|
||||
|
||||
constructor(body: PayloadBody, numDose: number) {
|
||||
constructor(body: PayloadBody, numDose: number = 0) {
|
||||
|
||||
let generic: PassDictionary = {
|
||||
this.receipts = body.receipts;
|
||||
this.shcReceipt = body.shcReceipt;
|
||||
this.rawData = body.rawData;
|
||||
this.generic = {
|
||||
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;
|
||||
}
|
||||
}
|
||||
if (body.rawData.length > 0) {
|
||||
processSHCReceipt(body.shcReceipt, this.generic);
|
||||
this.backgroundColor = COLORS.WHITE;
|
||||
this.labelColor = COLORS.BLACK;
|
||||
this.foregroundColor = COLORS.BLACK;
|
||||
this.img1x = Constants.img1xBlack;
|
||||
this.img2x = Constants.img2xBlack;
|
||||
} else {
|
||||
fullyVaccinated = processReceipt(body.receipts[numDose], generic);
|
||||
const fullyVaccinated = processReceipt(body.receipts[numDose], this.generic);
|
||||
if (fullyVaccinated) {
|
||||
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.receipts = body.receipts;
|
||||
this.rawData = body.rawData;
|
||||
this.generic = generic;
|
||||
if (body.rawData.length == 0) { // Ontario special handling
|
||||
// These are the non-SHC ON receipts, which expire Oct 22nd
|
||||
this.expirationDate = '2021-10-22T23:59:59-04:00';
|
||||
generic.auxiliaryFields.push({
|
||||
this.generic.auxiliaryFields.push({
|
||||
key: "expiry",
|
||||
label: "QR code expiry",
|
||||
value: '2021-10-22'
|
||||
|
@ -96,85 +102,100 @@ export class Payload {
|
|||
|
||||
function processReceipt(receipt: Receipt, generic: PassDictionary) : boolean {
|
||||
|
||||
console.log(`processing receipt #${receipt.numDoses}`);
|
||||
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();
|
||||
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'))
|
||||
vaccineNameProper = 'Pfizer (Comirnaty)'
|
||||
if (vaccineName.includes('PFIZER'))
|
||||
vaccineNameProper = 'Pfizer (Comirnaty)'
|
||||
|
||||
if (vaccineName.includes('MODERNA'))
|
||||
vaccineNameProper = 'Moderna (SpikeVax)'
|
||||
if (vaccineName.includes('MODERNA'))
|
||||
vaccineNameProper = 'Moderna (SpikeVax)'
|
||||
|
||||
if (vaccineName.includes('ASTRAZENECA') || vaccineName.includes('COVISHIELD'))
|
||||
vaccineNameProper = 'AstraZeneca (Vaxzevria)'
|
||||
if (vaccineName.includes('ASTRAZENECA') || vaccineName.includes('COVISHIELD'))
|
||||
vaccineNameProper = 'AstraZeneca (Vaxzevria)'
|
||||
|
||||
let doseVaccine = "#" + String(receipt.numDoses) + ": " + vaccineNameProper;
|
||||
let fullyVaccinated = false;
|
||||
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;
|
||||
}
|
||||
if (receipt.numDoses > 1 ||
|
||||
vaccineName.toLowerCase().includes('janssen') ||
|
||||
vaccineName.toLowerCase().includes('johnson') ||
|
||||
vaccineName.toLowerCase().includes('j&j')) {
|
||||
fullyVaccinated = true;
|
||||
}
|
||||
|
||||
if (generic.primaryFields.length == 0) {
|
||||
generic.primaryFields.push(
|
||||
{
|
||||
key: "vaccine",
|
||||
label: "Vaccine",
|
||||
value: doseVaccine
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
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(
|
||||
if (generic.primaryFields.length == 0) {
|
||||
generic.primaryFields.push(
|
||||
{
|
||||
key: "issuer",
|
||||
label: "Authorized Organization",
|
||||
value: receipt.organization
|
||||
},
|
||||
{
|
||||
key: "dov",
|
||||
label: "Vacc. Date",
|
||||
value: receipt.vaccinationDate,
|
||||
key: "vaccine",
|
||||
label: "Vaccine",
|
||||
value: doseVaccine
|
||||
}
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
if (generic.auxiliaryFields.length == 0) {
|
||||
generic.auxiliaryFields.push(
|
||||
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: receipt.organization
|
||||
},
|
||||
{
|
||||
key: "dov",
|
||||
label: "Vacc. Date",
|
||||
value: receipt.vaccinationDate,
|
||||
}
|
||||
);
|
||||
|
||||
if (generic.auxiliaryFields.length == 0) {
|
||||
generic.auxiliaryFields.push(
|
||||
{
|
||||
key: "name",
|
||||
label: "Name",
|
||||
value: name
|
||||
},
|
||||
{
|
||||
key: "dob",
|
||||
label: "Date of Birth",
|
||||
value: dateOfBirth
|
||||
});
|
||||
}
|
||||
|
||||
return fullyVaccinated;
|
||||
}
|
||||
|
||||
function processSHCReceipt(receipt: SHCReceipt, generic: PassDictionary) {
|
||||
|
||||
console.log(`processing receipt for origin ${receipt.cardOrigin}`);
|
||||
|
||||
if (generic.primaryFields.length == 0) {
|
||||
generic.primaryFields.push(
|
||||
{
|
||||
key: "name",
|
||||
label: "Name",
|
||||
value: name
|
||||
},
|
||||
{
|
||||
key: "dob",
|
||||
label: "Date of Birth",
|
||||
value: dateOfBirth
|
||||
});
|
||||
}
|
||||
|
||||
return fullyVaccinated;
|
||||
value: receipt.name
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
88
src/photo.ts
88
src/photo.ts
|
@ -1,11 +1,8 @@
|
|||
import {Constants} from "./constants";
|
||||
import {Payload, PayloadBody} from "./payload";
|
||||
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';
|
||||
import {QrCode,Encoding,PackageResult,QrFormat,PassPhotoCommon} from './passphoto-common';
|
||||
import { EncodeHintType } from "@zxing/library";
|
||||
import {PayloadBody} from "./payload";
|
||||
import { toBlob } from 'html-to-image';
|
||||
import {QrCode,PassPhotoCommon} from './passphoto-common';
|
||||
import { Encoder, QRByte, QRNumeric, ErrorCorrectionLevel } from '@nuintun/qrcode';
|
||||
|
||||
export class Photo {
|
||||
|
||||
|
@ -28,13 +25,7 @@ export class Photo {
|
|||
|
||||
const payload = results.payload;
|
||||
const qrCode = results.qrCode;
|
||||
|
||||
let receipt;
|
||||
if (results.payload.rawData.length == 0) {
|
||||
receipt = results.payload.receipts[numDose];
|
||||
} else {
|
||||
receipt = results.payload.receipts[numDose];
|
||||
}
|
||||
let receipt = results.payload.receipts[numDose];
|
||||
|
||||
const body = document.getElementById('pass-image');
|
||||
body.hidden = false;
|
||||
|
@ -75,25 +66,72 @@ export class Photo {
|
|||
}
|
||||
}
|
||||
|
||||
const codeWriter = new BrowserQRCodeSvgWriter();
|
||||
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 qrcode = new Encoder();
|
||||
|
||||
qrcode.setEncodingHint(true);
|
||||
qrcode.setErrorCorrectionLevel(ErrorCorrectionLevel.L);
|
||||
|
||||
const blobPromise = toBlob(body);
|
||||
return blobPromise;
|
||||
if (qrCode.message.includes('shc:/')) {
|
||||
// Write an SHC code in 2 chunks otherwise it won't render right
|
||||
qrcode.write(new QRByte('shc:/'));
|
||||
qrcode.write(new QRNumeric(qrCode.message.substring(5)));
|
||||
} else {
|
||||
// If this isn't an SHC code, just write it out as a string
|
||||
qrcode.write(qrCode.message);
|
||||
}
|
||||
|
||||
qrcode.make();
|
||||
const qrImage = new Image(220, 220);
|
||||
qrImage.src = qrcode.toDataURL(2, 15);
|
||||
document.getElementById('qrcode').appendChild(qrImage);
|
||||
|
||||
return toBlob(body);
|
||||
|
||||
} catch (e) {
|
||||
return Promise.reject(e);
|
||||
}
|
||||
}
|
||||
|
||||
private constructor(payload: Payload, qrCode: QrCode) {
|
||||
static async generateSHCPass(payloadBody: PayloadBody): Promise<Blob> {
|
||||
// Create Payload
|
||||
try {
|
||||
console.log('generateSHCPass');
|
||||
const results = await PassPhotoCommon.preparePayload(payloadBody);
|
||||
const qrCode = results.qrCode;
|
||||
const body = document.getElementById('shc-pass-image');
|
||||
body.hidden = false;
|
||||
|
||||
document.getElementById('shc-card-name').innerText = results.payload.shcReceipt.name;
|
||||
document.getElementById('shc-card-origin').innerText = results.payload.shcReceipt.cardOrigin;
|
||||
|
||||
const qrcode = new Encoder();
|
||||
|
||||
qrcode.setEncodingHint(true);
|
||||
qrcode.setErrorCorrectionLevel(ErrorCorrectionLevel.L);
|
||||
|
||||
if (qrCode.message.includes('shc:/')) {
|
||||
// Write an SHC code in 2 chunks otherwise it won't render right
|
||||
qrcode.write(new QRByte('shc:/'));
|
||||
qrcode.write(new QRNumeric(qrCode.message.substring(5)));
|
||||
} else {
|
||||
// If this isn't an SHC code, just write it out as a string
|
||||
qrcode.write(qrCode.message);
|
||||
}
|
||||
|
||||
qrcode.make();
|
||||
const qrImage = new Image(220, 220);
|
||||
qrImage.src = qrcode.toDataURL(2, 15);
|
||||
document.getElementById('shc-qrcode').appendChild(qrImage);
|
||||
|
||||
return toBlob(body);
|
||||
|
||||
} catch (e) {
|
||||
return Promise.reject(e);
|
||||
}
|
||||
}
|
||||
|
||||
private constructor() {
|
||||
|
||||
// make a png in buffer using the payload
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
|
262
src/process.ts
262
src/process.ts
|
@ -1,14 +1,12 @@
|
|||
import {PayloadBody, Receipt, HashTable} from "./payload";
|
||||
import * as PdfJS from 'pdfjs-dist/legacy/build/pdf'
|
||||
import jsQR, {QRCode} from "jsqr";
|
||||
import {QRCode} from "jsqr";
|
||||
import { getCertificatesInfoFromPDF } from "@ninja-labs/verify-pdf"; // ES6
|
||||
import {COLORS} from "./colors";
|
||||
import * as Sentry from '@sentry/react';
|
||||
import * as Decode from './decode';
|
||||
import {getScannedJWS, verifyJWS, decodeJWS} from "./shc";
|
||||
import { PNG } from 'pngjs/browser';
|
||||
|
||||
import { PDFPageProxy, TextContent, TextItem } from 'pdfjs-dist/types/src/display/api';
|
||||
import { PDFPageProxy, TextItem } from 'pdfjs-dist/types/src/display/api';
|
||||
|
||||
// import {PNG} from 'pngjs'
|
||||
// import {decodeData} from "./decode";
|
||||
|
@ -19,85 +17,82 @@ PdfJS.GlobalWorkerOptions.workerSrc = `https://cdnjs.cloudflare.com/ajax/libs/pd
|
|||
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
|
||||
let imageData: ImageData[];
|
||||
|
||||
switch (file.type) {
|
||||
case 'application/pdf':
|
||||
|
||||
///////// DELETE THE FOLLOWING CODE ON OCT 22ND //////////////////////
|
||||
const receiptType = await detectReceiptType(fileBuffer);
|
||||
console.log(`receiptType = ${receiptType}`);
|
||||
// receipt type is needed to decide if digital signature checking is needed
|
||||
if (receiptType == 'ON') {
|
||||
receipts = await loadPDF(fileBuffer) // receipt type is needed to decide if digital signature checking is needed
|
||||
// Bail out immediately, special case
|
||||
const receipts = await loadPDF(fileBuffer);
|
||||
return {receipts, rawData: '', shcReceipt: null};
|
||||
} else {
|
||||
const shcData = await processSHC(fileBuffer);
|
||||
receipts = shcData.receipts;
|
||||
rawData = shcData.rawData;
|
||||
///////// END OCT 22ND DELETE BLOCK //////////////////////
|
||||
imageData = await getImageDataFromPdf(fileBuffer);
|
||||
}
|
||||
break;
|
||||
=======
|
||||
import {PayloadBody} from "./payload";
|
||||
import * as PdfJS from 'pdfjs-dist'
|
||||
import jsQR, {QRCode} from "jsqr";
|
||||
import {decodeData} from "./decode";
|
||||
import {Result} from "@zxing/library";
|
||||
import {COLORS} from "./colors";
|
||||
|
||||
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> {
|
||||
let imageData: ImageData;
|
||||
|
||||
switch (file.type) {
|
||||
case 'application/pdf':
|
||||
console.log('pdf')
|
||||
// Read file
|
||||
const fileBuffer = await file.arrayBuffer();
|
||||
imageData = await getImageDataFromPdf(fileBuffer)
|
||||
break
|
||||
case 'image/png':
|
||||
case 'image/jpeg':
|
||||
case 'image/webp':
|
||||
case 'image/gif':
|
||||
console.log(`image ${file.type}`);
|
||||
imageData = [await getImageDataFromImage(file)];
|
||||
break;
|
||||
default:
|
||||
throw Error('invalidFileType')
|
||||
}
|
||||
|
||||
return {
|
||||
receipts: receipts,
|
||||
rawData: rawData
|
||||
}
|
||||
|
||||
|
||||
// Send back our SHC payload now
|
||||
return processSHC(imageData);
|
||||
}
|
||||
|
||||
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 docMetadata = await pdfDocument.getMetadata();
|
||||
const numItems = content.items.length;
|
||||
if (numItems == 0) { // QC has no text items
|
||||
console.log('detected QC');
|
||||
Sentry.setContext("QC-pdf-metadata", {
|
||||
numPages: pdfDocument.numPages,
|
||||
docMetadata: JSON.stringify(docMetadata)
|
||||
});
|
||||
Sentry.captureMessage('QC PDF Metadata for structure analysis');
|
||||
console.log(`PDF details: numPages=${pdfDocument.numPages}`);
|
||||
|
||||
return Promise.resolve('SHC');
|
||||
// Explicitly try to detect an ON PDF based on the headers in the PDF
|
||||
//console.log(`PDF details: metadata=${JSON.stringify(docMetadata)}`);
|
||||
|
||||
// The Ontario proof-of-vaccination receipts have several fixed unchanging pieces of metadata that we use for detection
|
||||
if (docMetadata.info['IsSignaturesPresent'] &&
|
||||
(docMetadata.info['Producer'] == 'PDFKit') &&
|
||||
(docMetadata.info['PDFFormatVersion'] == '1.7') &&
|
||||
(docMetadata.info['Title'] == 'COVID-19 vaccination receipt / Récépissé de vaccination contre la COVID-19')
|
||||
) {
|
||||
return Promise.resolve('ON');
|
||||
} else {
|
||||
for (let i = 0; i < numItems; i++) {
|
||||
let item = content.items[i] as TextItem;
|
||||
const value = item.str;
|
||||
// console.log(value);
|
||||
if (value.includes('BC Vaccine Card')) {
|
||||
console.log('detected BC');
|
||||
|
||||
Sentry.setContext("BC-pdf-metadata", {
|
||||
numPages: pdfDocument.numPages,
|
||||
docMetadata: JSON.stringify(docMetadata)
|
||||
});
|
||||
Sentry.captureMessage('BC PDF Metadata for structure analysis');
|
||||
|
||||
return Promise.resolve('SHC');
|
||||
} else if (value.includes('Proof of Vaccination') || value.includes('User Information')) {
|
||||
// possible missed QC PDF?
|
||||
Sentry.setContext("Possible-missed-QC-pdf-metadata", {
|
||||
numPages: pdfDocument.numPages,
|
||||
docMetadata: JSON.stringify(docMetadata),
|
||||
matchedValue: value
|
||||
});
|
||||
Sentry.captureMessage('Possible missed QC PDF Metadata for structure analysis');
|
||||
}
|
||||
}
|
||||
// If it's not an exact match to an ON proof-of-vaccination PDF, it will never pass validation anyways so treat it as an SHC PDF
|
||||
return Promise.resolve('SHC');
|
||||
}
|
||||
return Promise.resolve('ON');
|
||||
|
||||
}
|
||||
|
||||
async function loadPDF(fileBuffer : ArrayBuffer): Promise<HashTable<Receipt>> {
|
||||
|
@ -150,22 +145,21 @@ async function loadPDF(fileBuffer : ArrayBuffer): Promise<HashTable<Receipt>> {
|
|||
'-----END CERTIFICATE-----';
|
||||
|
||||
const pdfCert = result.pemCertificate.trim();
|
||||
const pdfOrg = result.issuedBy.organizationName;
|
||||
//const pdfOrg = result.issuedBy.organizationName;
|
||||
const issuedpemCertificate = (pdfCert == refcert.trim());
|
||||
|
||||
//console.log(`pdf is signed by this cert ${result.pemCertificate.trim()}`);
|
||||
//console.log(issuedpemCertificate);
|
||||
//console.log(`PDF is signed by ${result.issuedBy.organizationName}, issued to ${result.issuedTo.commonName}`);
|
||||
|
||||
// const bypass = window.location.href.includes('grassroots2');
|
||||
|
||||
if (( issuedpemCertificate )) {
|
||||
if (issuedpemCertificate) {
|
||||
//console.log('getting receipt details inside PDF');
|
||||
const receipt = await getPdfDetails(fileBuffer);
|
||||
// console.log(JSON.stringify(receipt, null, 2));
|
||||
return Promise.resolve(receipt);
|
||||
|
||||
} else {
|
||||
/* We don't need to track these anymore - this is all going away in a week anyways
|
||||
// According to the Sentry docs, this can be up to 8KB in size
|
||||
// https://develop.sentry.dev/sdk/data-handling/#variable-size
|
||||
Sentry.setContext("certificate", {
|
||||
|
@ -173,6 +167,7 @@ async function loadPDF(fileBuffer : ArrayBuffer): Promise<HashTable<Receipt>> {
|
|||
pdfOrg: pdfOrg,
|
||||
});
|
||||
Sentry.captureMessage('Certificate validation failed');
|
||||
*/
|
||||
console.error('invalid certificate');
|
||||
return Promise.reject(`invalid certificate + ${JSON.stringify(result)}`);
|
||||
}
|
||||
|
@ -203,10 +198,9 @@ async function getPdfDetails(fileBuffer: ArrayBuffer): Promise<HashTable<Receipt
|
|||
|
||||
const pdfDocument = await loadingTask.promise;
|
||||
// Load all dose numbers
|
||||
const { numPages } = pdfDocument;
|
||||
const receiptObj = {};
|
||||
|
||||
for (let pages = 1; pages <= numPages; pages++){
|
||||
for (let pages = 1; pages <= pdfDocument.numPages; pages++){
|
||||
const pdfPage = await pdfDocument.getPage(pages);
|
||||
const content = await pdfPage.getTextContent();
|
||||
const numItems = content.items.length;
|
||||
|
@ -244,17 +238,17 @@ async function getPdfDetails(fileBuffer: ArrayBuffer): Promise<HashTable<Receipt
|
|||
}
|
||||
}
|
||||
|
||||
async function getImageDataFromPdf(pdfPage: PDFPageProxy): Promise<ImageData> {
|
||||
async function getImageDataFromPdfPage(pdfPage: PDFPageProxy): Promise<ImageData> {
|
||||
|
||||
const pdfScale = 2;
|
||||
|
||||
const canvas = <HTMLCanvasElement>document.getElementById('canvas');
|
||||
const canvasContext = canvas.getContext('2d');
|
||||
const viewport = pdfPage.getViewport({scale: pdfScale})
|
||||
const viewport = pdfPage.getViewport({scale: pdfScale});
|
||||
|
||||
// Set correct canvas width / height
|
||||
canvas.width = viewport.width
|
||||
canvas.height = viewport.height
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
|
||||
// render PDF
|
||||
const renderTask = pdfPage.render({
|
||||
|
@ -265,54 +259,122 @@ async function getImageDataFromPdf(pdfPage: PDFPageProxy): Promise<ImageData> {
|
|||
await renderTask.promise;
|
||||
|
||||
// Return PDF Image Data
|
||||
return canvasContext.getImageData(0, 0, canvas.width, canvas.height)
|
||||
return canvasContext.getImageData(0, 0, canvas.width, canvas.height);
|
||||
|
||||
}
|
||||
|
||||
async function processSHC(fileBuffer : ArrayBuffer) : Promise<any> {
|
||||
function getImageDataFromImage(file: File): Promise<ImageData> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const canvas = <HTMLCanvasElement>document.getElementById('canvas');
|
||||
const canvasContext = canvas.getContext('2d');
|
||||
|
||||
// create Image object
|
||||
const img = new Image();
|
||||
|
||||
img.onload = () => {
|
||||
// constrain image to 2 Mpx
|
||||
const maxPx = 2000000;
|
||||
let width: number;
|
||||
let height: number;
|
||||
if (img.naturalWidth * img.naturalHeight > maxPx) {
|
||||
const ratio = img.naturalHeight / img.naturalWidth;
|
||||
width = Math.sqrt(maxPx / ratio);
|
||||
height = Math.floor(width * ratio);
|
||||
width = Math.floor(width);
|
||||
} else {
|
||||
width = img.naturalWidth;
|
||||
height = img.naturalHeight;
|
||||
}
|
||||
|
||||
// Set correct canvas width / height
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
// draw image into canvas
|
||||
canvasContext.clearRect(0, 0, width, height);
|
||||
canvasContext.drawImage(img, 0, 0, width, height);
|
||||
|
||||
// Obtain image data
|
||||
resolve(canvasContext.getImageData(0, 0, width, height));
|
||||
};
|
||||
|
||||
img.onerror = (e) => {
|
||||
reject(e);
|
||||
};
|
||||
|
||||
// start loading image from file
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
async function getImageDataFromPdf(fileBuffer: ArrayBuffer): Promise<ImageData[]> {
|
||||
|
||||
const typedArray = new Uint8Array(fileBuffer);
|
||||
const loadingTask = PdfJS.getDocument(typedArray);
|
||||
|
||||
const pdfDocument = await loadingTask.promise;
|
||||
console.log('SHC PDF loaded');
|
||||
const retArray = [];
|
||||
|
||||
// Load and return every page in our PDF
|
||||
for (let i = 1; i <= pdfDocument.numPages; i++) {
|
||||
console.log(`Processing PDF page ${i}`);
|
||||
const pdfPage = await pdfDocument.getPage(i);
|
||||
const imageData = await getImageDataFromPdfPage(pdfPage);
|
||||
retArray.push(imageData);
|
||||
}
|
||||
|
||||
return Promise.resolve(retArray);
|
||||
}
|
||||
|
||||
|
||||
async function processSHC(allImageData : ImageData[]) : Promise<PayloadBody> {
|
||||
|
||||
console.log('processSHC');
|
||||
|
||||
try {
|
||||
const typedArray = new Uint8Array(fileBuffer);
|
||||
const loadingTask = PdfJS.getDocument(typedArray);
|
||||
if (allImageData) {
|
||||
for (let i = 0; i < allImageData.length; i++) {
|
||||
|
||||
const pdfDocument = await loadingTask.promise;
|
||||
console.log('SHC PDF loaded');
|
||||
// Load all dose numbers
|
||||
const pdfPage = await pdfDocument.getPage(1);
|
||||
console.log('SHC PDF Page 1 loaded');
|
||||
const imageData = await getImageDataFromPdf(pdfPage);
|
||||
console.log('SHC PDF Page 1 image data loaded');
|
||||
const code : QRCode = await Decode.getQRFromImage(imageData);
|
||||
console.log('SHC code detected:');
|
||||
console.log(code);
|
||||
const imageData = allImageData[i];
|
||||
const code : QRCode = await Decode.getQRFromImage(imageData);
|
||||
//console.log(`SHC code result from page ${i}:`);
|
||||
//console.log(code);
|
||||
|
||||
if (!code) {
|
||||
return Promise.reject(new Error('No valid ON proof-of-vaccination digital signature found! Please make sure you download the PDF directly from covid19.ontariohealth.ca, Save as Files on your iPhone, and do NOT save/print it as a PDF!'));
|
||||
}
|
||||
if (code) {
|
||||
try {
|
||||
// We found a QR code of some kind - start analyzing now
|
||||
const rawData = code.data;
|
||||
const jws = getScannedJWS(rawData);
|
||||
const decoded = await decodeJWS(jws);
|
||||
|
||||
const rawData = code.data;
|
||||
const jws = getScannedJWS(rawData);
|
||||
//console.log(decoded);
|
||||
|
||||
const decoded = await decodeJWS(jws);
|
||||
|
||||
// console.log(decoded);
|
||||
const verified = verifyJWS(jws, decoded.iss);
|
||||
|
||||
const verified = verifyJWS(jws, decoded.iss);
|
||||
|
||||
if (verified) {
|
||||
const receipts = Decode.decodedStringToReceipt(decoded);
|
||||
//console.log(receipts);
|
||||
return Promise.resolve({receipts: receipts, rawData: rawData});
|
||||
|
||||
} else {
|
||||
return Promise.reject(`Issuer ${decoded.iss} cannot be verified.`);
|
||||
if (verified) {
|
||||
const shcReceipt = Decode.decodedStringToReceipt(decoded);
|
||||
//console.log(shcReceipt);
|
||||
return Promise.resolve({receipts: null, shcReceipt, rawData});
|
||||
} else {
|
||||
// If we got here, we found an SHC which was not verifiable. Consider it fatal and stop processing.
|
||||
return Promise.reject(`Issuer ${decoded.iss} cannot be verified.`);
|
||||
}
|
||||
} catch (e) {
|
||||
// We blew up during processing - log it and move on to the next page
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we got here, no SHC was detected and successfully decoded.
|
||||
// The vast majority of our processed things right now are ON proof-of-vaccination PDFs, not SHC docs, so assume anything
|
||||
// that blew up here was a malformed ON proof-of-vaccination and create an appropriate error message for that
|
||||
return Promise.reject(new Error('No valid ON proof-of-vaccination digital signature found! Please make sure you download the PDF directly from covid19.ontariohealth.ca, Save as Files on your iPhone, and do NOT save/print it as a PDF!'));
|
||||
|
||||
} catch (e) {
|
||||
Sentry.captureException(e);
|
||||
return Promise.reject(e);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
|
@ -28,7 +28,7 @@ function getScannedJWS(shcString) {
|
|||
function verifyJWS(jws, iss) {
|
||||
const issuer = issuers.find(el => el.iss === iss);
|
||||
if (!issuer) {
|
||||
error = new Error("Unknown issuer " + iss);
|
||||
error = new Error(`Unknown issuer ${iss}`);
|
||||
error.customMessage = true;
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
|
17
yarn.lock
17
yarn.lock
|
@ -145,6 +145,13 @@
|
|||
"@nodelib/fs.scandir" "2.1.5"
|
||||
"fastq" "^1.6.0"
|
||||
|
||||
"@nuintun/qrcode@^3.0.1":
|
||||
"integrity" "sha512-0dY347xYgJvmBP0I75UHeVEx7JeDczahhEYaS03uh9bhCqa3FycdtLPxWulQh/r3OsX5lpi+z3mEXfAaQtAPwQ=="
|
||||
"resolved" "https://registry.npmjs.org/@nuintun/qrcode/-/qrcode-3.0.1.tgz"
|
||||
"version" "3.0.1"
|
||||
dependencies:
|
||||
"tslib" "^2.0.1"
|
||||
|
||||
"@sentry/browser@^6.13.2", "@sentry/browser@6.13.2":
|
||||
"integrity" "sha512-bkFXK4vAp2UX/4rQY0pj2Iky55Gnwr79CtveoeeMshoLy5iDgZ8gvnLNAz7om4B9OQk1u7NzLEa4IXAmHTUyag=="
|
||||
"resolved" "https://registry.npmjs.org/@sentry/browser/-/browser-6.13.2.tgz"
|
||||
|
@ -1473,6 +1480,11 @@
|
|||
"resolved" "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz"
|
||||
"version" "1.0.0"
|
||||
|
||||
"fsevents@~2.3.1", "fsevents@~2.3.2":
|
||||
"integrity" "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="
|
||||
"resolved" "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz"
|
||||
"version" "2.3.2"
|
||||
|
||||
"function-bind@^1.1.1":
|
||||
"integrity" "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
|
||||
"resolved" "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz"
|
||||
|
@ -3315,6 +3327,11 @@
|
|||
"resolved" "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz"
|
||||
"version" "1.14.1"
|
||||
|
||||
"tslib@^2.0.1":
|
||||
"integrity" "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
|
||||
"resolved" "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz"
|
||||
"version" "2.3.1"
|
||||
|
||||
"tty-browserify@0.0.0":
|
||||
"integrity" "sha1-oVe6QC2iTpv5V/mqadUk7tQpAaY="
|
||||
"resolved" "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.0.tgz"
|
||||
|
|
Loading…
Reference in New Issue