Merge remote-tracking branch 'origin/main' into main

This commit is contained in:
Coteh 2021-10-03 12:23:52 -04:00
commit 0f31cdd252
27 changed files with 3754 additions and 3087 deletions

5
.env.local Normal file
View File

@ -0,0 +1,5 @@
API_BASE_URL=http://localhost
VERIFIER_HOST=http://localhost:5001/grassroot-verifier/us-central1
HITCOUNT_HOST=http://localhost:8080
REGISTRATION_HOST=http://localhost:5001/grassroot-verifier/us-central1
FUNCTION_SUFFIX=v2

2
.gitignore vendored
View File

@ -26,11 +26,9 @@ yarn-error.log*
# local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Idea files
.idea
.env.production

View File

@ -25,6 +25,10 @@ docker build . -t covidpass
docker run -t -i -p 3000:3000 covidpass
```
### Integration with other repos required
Docs being developed/tested on Sep 26. Should be done tomorrow.
# FAQ
#### I do not want to trust a third party with my vaccination data, does this tool respect my privacy?

112
SETUP.md Normal file
View File

@ -0,0 +1,112 @@
# Environment Setup
The whole solution is made up of 4 repos.
1. covidpass (Wallet Pass creation front-end, port 3000)
2. covidpassApiDotNet (Wallet Pass signing service, Apple specific, port 80)
3. verifier (web app for scanning, port 5001; GCP cloud functions /register /verify in support of #1)
4. hit-counter (simple python script running in VM, web enabled using flask, port 8080)
The steps were tested against a standard ubuntu LTS 18 vm running in GCP
## GCP console - Firewall - New Firewall Rule
* allow-covidpass-ports, port 3000,80,5001,5003,8080 tcp/inbound
## GCP console - VM setup
* ubuntu LTS 18, 2 core, 4Gb RAM, allow all GCP API, network tag (allow-covidpass-ports), fixed external ip will be helpful
ssh into the newly created vm
Install docker
* https://docs.docker.com/engine/install/ubuntu/
Enable non-root usage of docker
```sh
sudo groupadd docker
sudo usermod -aG docker ${USER}
```
exit SSH session and login again
Install yarn
```sh
curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add -
echo "deb https://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list
sudo apt-get update && sudo apt-get install yarn
```
Upgrade node to 14
```sh
sudo apt-get install -y nodejs
```
Prepare to start
```sh
mkdir web
cd web
```
Repo 1 (https://github.com/billylo1/covidpass)
git clone https://github.com/billylo1/covidpass.git
cd covidpass
yarn install
note external IP of your dev machines
modify .env.local and replace localhost with your {vm-external-ip}
yarn dev
access it from your workstation's browser (http://vm-external-ip:3000)
you should see on the yarn output compiling... sentry initialized and browser showing page
Repo 2 (https://github.com/billylo1/CovidPassApiNet)
cd ~/web
git clone https://github.com/billylo1/CovidPassApiNet
cd CovidPassApiNet/CovidPassApiNet
cp appsettings.example.json appsettings.json
setup Apple Developer Certificate (assume current directory is the above)
1. Sign into your Apple Developer Account
2. Go to Certificates, Identifiers and Profiles
3. Register a new Pass Type Identifier under the Identifiers tab
4. Create a new Pass Type ID Certificate under the Certificates tab
5. Select your previously created Pass Type Identifier in the process
6. Move your new certificate to the My Certificates tab in the keychain
7. Export your certificate as a .p12 file (make a note of passphrase)
8. Create a text file named AppleDeveloperPassword with your passphrase in it
9. Install node.js and download the passkit-keys script
10. Create a keys folder and put the .p12 file inside
11. Run ./passkit-keys <path to your keys folder>
12. copy the .pem file to ~/web/CovidPassApiNet/CovidPassApiNet/AppDeveloperCerticate.pem
13. Open keychain - System Keychain - Certificates
14. Export Apple Developer Relations Certification Authority to AppleCaCertificate.pem
15. chmod 600 Apple*.pem
(Reminder: pls protect these files as they contain private key and passphrases. Do not add them to your repo.)
docker build . -t covidpassapinet
docker run covidpassapinet -p 80:80
Repo 3 (https://github.com/billylo1/verifier)
cd ~/web
git clone https://github.com/billylo1/verifier
sudo npm install -g firebase-tools
firebase init
sudo apt install default-jre
firebase emulators:start
Repo 4 (https://github.com/billylo1/hit-counter)
cd ~/web
git clone https://github.com/billylo1/hit-counter
sudo apt-get install python3.8 python3-pip
python3 server.py

View File

@ -1,5 +1,6 @@
docker build . -t covidpass -t gcr.io/broadcast2patients/covidpass
docker push gcr.io/broadcast2patients/covidpass
docker image prune
#gcloud config set project broadcast2patients
#gcloud config set run/region us-east1
#gcloud run deploy covidpass --image gcr.io/broadcast2patients/covidpass:latest --platform managed

View File

@ -8,26 +8,27 @@ interface AlertProps {
function Alert(props: AlertProps): JSX.Element {
const { t } = useTranslation(['index', 'errors']);
let color = 'red';
let spanStyle = 'bg-red-100 border border-red-400 text-red-700';
let svgStyle = 'text-red-500';
let icon;
switch (props.type) {
case 'error':
color = 'red'
// icon = () =>
// <svg className="w-4 h-4 fill-current" viewBox="0 0 20 20"><path d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" fill-rule="evenodd"></path></svg>
break;
case 'warning':
color = 'yellow'
spanStyle = 'bg-yellow-100 border border-yellow-400 text-yellow-700';
svgStyle = 'text-yellow-500';
break;
}
return (
<div className={`flex items-center bg-red-100 border border-red-400 text-red-700 px-4 py-3 mt-5 rounded relative`} role="alert">
<div className={`flex items-center ${spanStyle} px-4 py-3 mt-5 rounded relative`} role="alert">
{icon && icon()}
<span className="block sm:inline pr-6" id="message">{props.message}</span>
{props.onClose && <span className="absolute right-0 px-4 py-3" onClick={props.onClose}>
<svg className={`fill-current h-6 w-6 text-red-500`} role="button" xmlns="http://www.w3.org/2000/svg"
<svg className={`fill-current h-6 w-6 ${svgStyle}`} role="button" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20">
<title>{t('index:errorClose')}</title>
<path

View File

@ -30,74 +30,64 @@ function Form(): JSX.Element {
// Currently selected color
const [selectedColor, setSelectedColor] = useState<COLORS>(COLORS.WHITE);
// Currently selected dose
const [selectedDose, setSelectedDose] = useState<number>(2);
// Global camera controls
const [globalControls, setGlobalControls] = useState<IScannerControls>(undefined);
// Currently selected QR Code / File. Only one of them is set.
const [qrCode, setQrCode] = useState<Result>(undefined);
const [file, setFile] = useState<File>(undefined);
const [payloadBody, setPayloadBody] = useState<PayloadBody>(undefined);
const [loading, setLoading] = useState<boolean>(false);
const [saveLoading, setSaveLoading] = useState<boolean>(false);
const [fileLoading, setFileLoading] = useState<boolean>(false);
const [passCount, setPassCount] = useState<string>('');
const [generated, setGenerated] = useState<boolean>(false); // this flag represents the file has been used to generate a pass
const [isDisabledAppleWallet, setIsDisabledAppleWallet] = useState<boolean>(false);
const [errorMessages, _setErrorMessages] = useState<Array<string>>([]);
const [warningMessages, _setWarningMessages] = useState<Array<string>>([]);
const [addErrorMessages, _setAddErrorMessages] = useState<Array<string>>([]);
const [fileErrorMessages, _setFileErrorMessages] = useState<Array<string>>([]);
const [showDoseOption, setShowDoseOption] = useState<boolean>(false);
// const [warningMessages, _setWarningMessages] = useState<Array<string>>([]);
const hitcountHost = 'https://stats.vaccine-ontario.ca';
useEffect(() => {
if (passCount.length == 0) {
getPassCount();
}
}, []);
const getPassCount = async () => {
const hitCount = await getHitCount();
setPassCount(hitCount);
};
async function getHitCount() {
try {
const request = `${hitcountHost}/nocount?url=pass.vaccine-ontario.ca`;
let response = await fetch(request);
const counter = await response.text();
return Promise.resolve(counter);
} catch (e) {
console.error(e);
return Promise.reject(e);
}
}
// Check if there is a translation and replace message accordingly
const setErrorMessage = (message: string) => {
const setAddErrorMessage = (message: string) => {
if (!message) {
return;
}
const translation = t('errors:'.concat(message));
_setErrorMessages(Array.from(new Set([...errorMessages, translation !== message ? translation : message])));
_setAddErrorMessages(Array.from(new Set([...addErrorMessages, translation !== message ? translation : message])));
};
const setWarningMessage = (message: string) => {
const setFileErrorMessage = (message: string) => {
if (!message) {
return;
}
const translation = t('errors:'.concat(message));
_setWarningMessages(Array.from(new Set([...warningMessages, translation !== message ? translation : message])));
}
_setFileErrorMessages(Array.from(new Set([...addErrorMessages, translation !== message ? translation : message])));
};
const deleteErrorMessage = (message: string) =>{
console.log(errorMessages)
_setErrorMessages(errorMessages.filter(item => item !== message))
// const setWarningMessage = (message: string) => {
// if (!message) {
// return;
// }
// const translation = t('errors:'.concat(message));
// _setWarningMessages(Array.from(new Set([...warningMessages, translation !== message ? translation : message])));
// }
const deleteAddErrorMessage = (message: string) =>{
_setAddErrorMessages(addErrorMessages.filter(item => item !== message))
}
const deleteFileErrorMessage = (message: string) =>{
_setFileErrorMessages(addErrorMessages.filter(item => item !== message))
}
// File Input ref
@ -109,17 +99,57 @@ function Form(): JSX.Element {
inputFile.current.addEventListener('change', () => {
let selectedFile = inputFile.current.files[0];
if (selectedFile !== undefined) {
setFileLoading(true);
setQrCode(undefined);
setFile(selectedFile);
setPayloadBody(undefined);
setFile(undefined);
setShowDoseOption(false);
setGenerated(false);
deleteErrorMessage(t('errors:'.concat('noFileOrQrCode')));
deleteAddErrorMessage(t('errors:'.concat('noFileOrQrCode')));
_setFileErrorMessages([]);
checkBrowserType();
getPayload(selectedFile);
}
});
}
checkBrowserType();
}, [inputFile])
async function getPayload(file){
try {
const payload = await getPayloadBodyFromFile(file);
setPayloadBody(payload);
setFileLoading(false);
setFile(file);
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);
if (e != undefined) {
console.error(e);
Sentry.captureException(e);
if (e.message != undefined) {
setFileErrorMessage(e.message);
} else {
setFileErrorMessage("Unable to continue.");
}
} else {
setFileErrorMessage("Unexpected error. Sorry.");
}
}
}
// Show file Dialog
async function showFileDialog() {
inputFile.current.click();
@ -127,7 +157,7 @@ function Form(): JSX.Element {
async function gotoOntarioHealth(e) {
e.preventDefault();
window.location.href = 'https://covid19.ontariohealth.ca';
window.open('https://covid19.ontariohealth.ca','_blank');
}
async function goToFAQ(e) {
e.preventDefault();
@ -153,13 +183,13 @@ function Form(): JSX.Element {
try {
deviceList = await BrowserQRCodeReader.listVideoInputDevices();
} catch (e) {
setErrorMessage('noCameraAccess');
setAddErrorMessage('noCameraAccess');
return;
}
// Check if camera device is present
if (deviceList.length == 0) {
setErrorMessage("noCameraFound");
setAddErrorMessage("noCameraFound");
return;
}
@ -183,7 +213,7 @@ function Form(): JSX.Element {
setIsCameraOpen(false);
}
if (error !== undefined) {
setErrorMessage(error.message);
setAddErrorMessage(error.message);
}
}
)
@ -193,71 +223,64 @@ function Form(): JSX.Element {
}
async function incrementCount() {
try {
if (typeof generated == undefined || !generated) {
const request = `${hitcountHost}/count?url=pass.vaccine-ontario.ca`;
console.log(request);
//console.log(request);
let response = await fetch(request);
console.log(request);
//console.log(response);
const counter = await response.text(); // response count is not used intentionally so it always goes up by 1 only even if the server has changed
let newPasscount = Number(passCount) + 1;
console.log(counter);
setPassCount(counter);
setGenerated(true);
console.log(`new PassCount = ${newPasscount}`);
}
} catch (e) {
console.error(e);
return Promise.reject(e);
}
}
// Add Pass to wallet
async function addToWallet(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setLoading(true);
setSaveLoading(true);
if (!file && !qrCode) {
setErrorMessage('noFileOrQrCode')
setLoading(false);
setAddErrorMessage('noFileOrQrCode')
setSaveLoading(false);
return;
}
const color = selectedColor;
let payloadBody: PayloadBody;
try {
if (file) {
//console.log('> get payload');
payloadBody = await getPayloadBodyFromFile(file, color);
const passName = payloadBody.receipt.name.replace(' ', '-');
const vaxName = payloadBody.receipt.vaccineName.replace(' ', '-');
const passDose = payloadBody.receipt.numDoses;
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];
} 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');
let pass = await PassData.generatePass(payloadBody);
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);
setLoading(false);
setSaveLoading(false);
}
@ -269,16 +292,16 @@ function Form(): JSX.Element {
Sentry.captureException(e);
if (e.message != undefined) {
setErrorMessage(e.message);
setAddErrorMessage(e.message);
} else {
setErrorMessage("Unable to continue.");
setAddErrorMessage("Unable to continue.");
}
} else {
setErrorMessage("Unexpected error. Sorry.");
setAddErrorMessage("Unexpected error. Sorry.");
}
setLoading(false);
setSaveLoading(false);
}
}
@ -287,22 +310,34 @@ function Form(): JSX.Element {
async function saveAsPhoto() {
setLoading(true);
setSaveLoading(true);
if (!file && !qrCode) {
setErrorMessage('noFileOrQrCode');
setLoading(false);
setAddErrorMessage('noFileOrQrCode');
setSaveLoading(false);
return;
}
let payloadBody: PayloadBody;
try {
payloadBody = await getPayloadBodyFromFile(file, COLORS.GREEN);
await incrementCount();
let photoBlob = await Photo.generatePass(payloadBody);
saveAs(photoBlob, 'pass.png');
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, passDose);
saveAs(photoBlob, covidPassFilename);
// need to clean up
const qrcodeElement = document.getElementById('qrcode');
@ -311,11 +346,11 @@ function Form(): JSX.Element {
const body = document.getElementById('pass-image');
body.hidden = true;
setLoading(false);
setSaveLoading(false);
} catch (e) {
Sentry.captureException(e);
setErrorMessage(e.message);
setLoading(false);
setAddErrorMessage(e.message);
setSaveLoading(false);
}
}
const verifierLink = () => <li className="flex flex-row items-center">
@ -330,30 +365,35 @@ function Form(): JSX.Element {
</p>
</li>
const setDose = (e) => {
setSelectedDose(e.target.value);
}
function checkBrowserType() {
// if (isIPad13) {
// setErrorMessage('Sorry. Apple does not support the use of Wallet on iPad. Please use iPhone/Safari.');
// setAddErrorMessage('Sorry. Apple does not support the use of Wallet on iPad. Please use iPhone/Safari.');
// setIsDisabledAppleWallet(true);
// }
// if (!isSafari && !isChrome) {
// setErrorMessage('Sorry. Apple Wallet pass can be added using Safari or Chrome only.');
// setAddErrorMessage('Sorry. Apple Wallet pass can be added using Safari or Chrome only.');
// setIsDisabledAppleWallet(true);
// }
// if (isIOS && (!osVersion.includes('13') && !osVersion.includes('14') && !osVersion.includes('15'))) {
// setErrorMessage('Sorry, iOS 13+ is needed for the Apple Wallet functionality to work')
// setAddErrorMessage('Sorry, iOS 13+ is needed for the Apple Wallet functionality to work')
// setIsDisabledAppleWallet(true);
// }
if (isIOS && !isSafari) {
// setErrorMessage('Sorry, only Safari can be used to add a Wallet Pass on iOS');
setErrorMessage('Sorry, only Safari can be used to add a Wallet Pass on iOS');
// setAddErrorMessage('Sorry, only Safari can be used to add a Wallet Pass on iOS');
setAddErrorMessage('Sorry, only Safari can be used to add a Wallet Pass on iOS');
setIsDisabledAppleWallet(true);
console.log('not safari')
} else if (!isIOS) {
setWarningMessage('Only Safari on iOS is officially supported for Wallet import at the moment - ' +
'for other platforms, please ensure you have an application which can open Apple Wallet .pkpass files');
setIsDisabledAppleWallet(false);
}
// } else if (!isIOS) {
// setWarningMessage('Only Safari on iOS is officially supported for Wallet import at the moment - ' +
// 'for other platforms, please ensure you have an application which can open Apple Wallet .pkpass files');
// setIsDisabledAppleWallet(false);
// }
}
return (
@ -363,13 +403,14 @@ function Form(): JSX.Element {
<div className="space-y-5">
<p>
{t('index:visit')}&nbsp;
<Link href="https://covid19.ontariohealth.ca">
<a className="underline" target="_blank">
{t('index:ontarioHealth')}
</a>
</Link>&nbsp;
{t('index:downloadSignedPDF')}
{t('index:downloadSignedPDF')}<br/><br/>
{t('index:reminderNotToRepeat')}
</p>
<button id="ontariohealth" onClick={gotoOntarioHealth}
@ -382,19 +423,26 @@ function Form(): JSX.Element {
<Card step="2" heading={t('index:selectCertificate')} content={
<div className="space-y-5">
<p>{t('index:selectCertificateDescription')}</p>
<p>{t('index:selectCertificateReminder')}</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<div className="grid grid-cols-1 md:grid-cols-2 gap-5 items-center justify-start">
<button
type="button"
onClick={showFileDialog}
className="focus:outline-none h-20 bg-green-600 hover:bg-gray-700 text-white font-semibold rounded-md">
{t('index:openFile')}
</button>
<div id="spin" className={fileLoading ? undefined : "hidden"}>
<svg className="animate-spin h-5 w-5 ml-4" viewBox="0 0 24 24">
<circle className="opacity-0" cx="12" cy="12" r="10" stroke="currentColor"
strokeWidth="4"/>
<path className="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/>
</svg>
</div>
</div>
<input type='file'
id='file'
accept="application/pdf"
accept="application/pdf,image/png"
ref={inputFile}
style={{display: 'none'}}
/>
@ -415,10 +463,37 @@ function Form(): JSX.Element {
</span>
</div>
}
{fileErrorMessages.map((message, i) =>
<Alert message={message} key={'error-' + i} type="error" />
)}
</div>
}/>
<Card step="3" heading={t('index:addToWalletHeader')} content={
{showDoseOption && <Card step="3" heading={'Choose dose number'} content={
<div className="space-y-5">
<p>
{t('index:formatChange')}
<br /><br />
{t('index:saveMultiple')}
</p>
<link href="https://cdn.jsdelivr.net/npm/@tailwindcss/custom-forms@0.2.1/dist/custom-forms.css" rel="stylesheet"/>
<div className="block">
<div className="mt-2">
{payloadBody && Object.keys(payloadBody.receipts).map(key =>
<div key={key}>
<label className="inline-flex items-center">
<input onChange={setDose} type="radio" className="form-radio" name="radio" value={key} checked={parseInt(key) == selectedDose} />
<span className="ml-2">Dose {key}</span>
</label>
</div>
)}
</div>
</div>
</div>
} />}
<Card step={showDoseOption ? '4' : '3'} heading={t('index:addToWalletHeader')} content={
<div className="space-y-5">
{/* <p>
{t('index:dataPrivacyDescription')}
@ -434,24 +509,21 @@ function Form(): JSX.Element {
<Check text={t('piiNotSent')}/>
<Check text={t('openSourceTransparent')}/>
{verifierLink()}
{passCount && <Check text={passCount + ' ' + t('numPasses')}/>}
{/* <Check text={t('hostedInEU')}/> */}
</ul>
</div>
<div className="flex flex-row items-center justify-start">
<button disabled={isDisabledAppleWallet || loading} id="download" type="submit" value='applewallet' name='action'
<button disabled={isDisabledAppleWallet || saveLoading ||!payloadBody} id="download" type="submit" value='applewallet' name='action'
className="focus:outline-none bg-green-600 py-2 px-3 text-white font-semibold rounded-md disabled:bg-gray-400">
{t('index:addToWallet')}
</button>
&nbsp;&nbsp;&nbsp;&nbsp;
<button id="saveAsPhoto" type="button" disabled={loading} value='photo' name='action' onClick={saveAsPhoto}
<button id="saveAsPhoto" type="button" disabled={saveLoading || !payloadBody} value='photo' name='action' onClick={saveAsPhoto}
className="focus:outline-none bg-green-600 py-2 px-3 text-white font-semibold rounded-md disabled:bg-gray-400">
{t('index:saveAsPhoto')}
</button>
<div id="spin" className={loading ? undefined : "hidden"}>
<div id="spin" className={saveLoading ? undefined : "hidden"}>
<svg className="animate-spin h-5 w-5 ml-4" viewBox="0 0 24 24">
<circle className="opacity-0" cx="12" cy="12" r="10" stroke="currentColor"
strokeWidth="4"/>
@ -460,12 +532,12 @@ function Form(): JSX.Element {
</svg>
</div>
</div>
{errorMessages.map((message, i) =>
{addErrorMessages.map((message, i) =>
<Alert message={message} key={'error-' + i} type="error" />
)}
{warningMessages.map((message, i) =>
{/* {warningMessages.map((message, i) =>
<Alert message={message} key={'warning-' + i} type="warning" />
)}
)} */}
</div>
}/>

View File

@ -1,9 +1,13 @@
import {useTranslation} from 'next-i18next';
import usePassCount from '../src/hooks/use_pass_count';
import Link from 'next/link'
function Logo(): JSX.Element {
const { t } = useTranslation('common');
const passCount = usePassCount();
const displayPassCount = (passCount? ` - ${passCount} receipts processed to date!` : '');
return (
<Link href="/">
@ -21,6 +25,7 @@ function Logo(): JSX.Element {
</svg>
<h1 className="text-3xl font-bold">
{t('common:title')}
{/* {displayPassCount} */}
</h1>
</a>
</Link>

View File

@ -1,5 +1,8 @@
import React from "react";
import {useTranslation} from 'next-i18next';
import usePassCount from "../src/hooks/use_pass_count";
import Head from 'next/head'
import Logo from './Logo'
import Link from 'next/link'
@ -10,7 +13,6 @@ interface PageProps {
function Page(props: PageProps): JSX.Element {
const { t } = useTranslation('common');
return (
<div className="md:w-2/3 xl:w-2/5 md:mx-auto flex flex-col min-h-screen justify-center px-5 py-12">
<Head>
@ -34,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-25 (v1.9.8)</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>
@ -68,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>
@ -87,6 +97,7 @@ function Page(props: PageProps): JSX.Element {
<br/>
<br/>
</div>
<canvas id="canvas" />
</div>
)
}

View File

@ -4,3 +4,4 @@ gcloud config set project broadcast2patients
gcloud config set run/region us-east1
gcloud run services update-traffic covidpass --to-latest
gcloud run deploy covidpass --image gcr.io/broadcast2patients/covidpass:latest --platform managed
docker image prune

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

@ -8,25 +8,25 @@ function Faq(): JSX.Element {
const { t } = useTranslation(['common', 'index', 'faq']);
const questionList = [
{description: 'Which version of iOS does this support?', answer: 'iOS 13.7 is the minimum at the moment. We are looking for adjustments for older iOS versions, but it will take a bit of time.'},
{description: 'I\'m having issues with adding it to my iPhone 6.', answer: 'Unfortunately, the iPhone 6 supports up to iOS 12.5 while the minimum requirement is iOS 13.7. In the meantime you can try it on a computer or another device and save it as a photo.'},
{description: 'I\'m having issues with adding it to my iPhone 6.', answer: 'Unfortunately, the iPhone 6 supports up to iOS 12 while the minimum requirement for our app is iOS 13.7 however, we are looking for ways around this to make it more accessible. In the meantime you can try it on a computer or another device and save it as either a wallet card or a photo - if you save it as a card, you can then email it to your iPhone and you will be able to import it into Apple Wallet that way.'},
{description: "What are the supported browsers?", answer: 'For iPhones, only Safari is supported for importing to your Apple Wallet. For any other devices, we recommend that you save it as photo using your browser of choice. Browsers built internally into mobile apps (e.g. Facebook, Twitter, Instagram) are known to have issues.'},
{description: "How is my private information handled?", answer: 'Your proof-of-vaccination PDF (and most of the information in it) never leaves your device, and does NOT get sent to our server - the only information we send and store is non-personally-identifiable information such as vaccine type, date, and which organization gave you the vaccine. We share your concern about personal data being stored and lost, which is why we chose not to store or send any of it to our servers so there is no chance of it being lost or leaked.'},
{description: 'Do you have plans for Android support?', answer: 'Yes. We are working with Google to gain access to the APIs required. Meanwhile, you can also use this tool to download an Apple Wallet pass and import that into Google Pay Wallet using apps such as Pass2Pay or simply save it as a photo.'},
{description: 'I have a Red/White OHIP card. Can I still use this tool?', answer: 'Yes you can! Just call the Provincial Vaccine Contact Centre at 1-833-943-3900. The call centre agent can email you a copy of the receipt.'},
{description: 'I dont\'t have a health card. Can I still use this tool?', answer: 'First contact your local public health unit to verify your identity and receive a COVIDcovid ID/Personal Access Code. You can then call the Provincial Vaccine Contact Centre at 1-833-943-3900 to get an email copy of your receipt.'},
{description: 'I do not have a health card. Can I still use this tool?', answer: 'First contact your local public health unit to verify your identity and receive a COVIDcovid ID/Personal Access Code. You can then call the Provincial Vaccine Contact Centre at 1-833-943-3900 to get an email copy of your receipt.'},
{description: 'I\'m seeing an error message saying “Failed byte range verification." What do I do?', answer: 'If you see this error then please try re-downloading your receipt from the provincial proof-of-vaccination portal and trying again. We have received reports from some people that this has resolved the problem for them.'},
{description: 'What does the colour of the Apple Wallet pass mean?', answer: 'Dose 1 is shown as Orange; dose 2+ in green for easy differentiation without reading the text. For the Janssen (Johnson & Johnson) vaccine, dose 1 is shown as green.'},
{description: 'Should I use the official provincial apps when they come out on 22nd October?', answer: 'YES. Once the official QR code from the province is available, Once the official QR code from the province is available, please come back to this site and you will be able to generate a new vaccination receipt which uses that new QR code'},
{description: 'How is the data on my vaccination receipt processed?', answer: 'It checks the receipt for an official signature from the province. If present, the receipt data is converted into Apple\'s format and then added into your iOS Wallet app.'},
{description: 'Should I use the official provincial apps when they come out on 22nd October?', answer: 'YES. Once the official QR code from the province is available, please come back to this site and you will be able to generate a new Apple Wallet pass which contains that new QR code'},
{description: 'How is the data on my vaccination receipt processed?', answer: 'Inside your local web browser, it checks the receipt for a digital signature from the provincial proof-of-vaccination system. If present, the receipt data is converted into Apple\'s format and then added into your iOS Wallet app.'},
{description: 'How can organizations validate this QR code?', answer: 'You can use our verifier app at verifier.vaccine-ontario.ca to verify these passes quickly if you are a business - you should also be able to use any normal QR code scanner to scan this code and it will take you to a verification site which tells you whether the receipt is valid or not'},
{description: 'Can I use the same iPhone to store passes for my entire family?', answer: 'Yes.'},
{description: 'Is this free and non-commercial?', answer: 'Similar to VaxHuntersCanada, there are no commerical interests. Just volunteers trying to do our part to help the community.'},
{description: 'How about BC, Quebec and Alberta?', answer: 'We will be investigating BC shortly. If you are interested in contributing, email us at grassroots@vaccine-ontario.ca'},
{description: 'Is this free and non-commercial?', answer: 'Similar to VaxHuntersCanada, there are no commercial interests. Just volunteers trying to do our part to help the community.'},
{description: 'How about support for other provinces?', answer: 'We will be investigating BC and Québec support shortly. If you are interested in contributing, please email us at grassroots@vaccine-ontario.ca'},
{description: 'How about Apple Watch?', answer: 'If you have iCloud sync enabled, you will see the pass on the watch too.'},
{description: 'Why have we taken time to build this?', answer: 'Gives Ontarians/organizations something easy to use (volunteered-developed, unofficial) until the official provincial app comes out in October.'},
{description: 'Who made this?', answer: 'The same group of volunteers who created the all-in-one vaccine appointment finding tool at vaccine-ontario.ca.'},
{description: 'How can I stay up-to-date on your progress?', answer: 'We will post regular updates on Twitter @grassroots_team.'},
{description: 'I have more questions. Can you please help me?', answer: 'Sure. Just email us at grassroots@vaccine-ontario.ca.'}
{description: 'Who made this?', answer: 'The same group of volunteers who created the all-in-one vaccine appointment finding tool at vaccine-ontario.ca'},
{description: 'How can I stay up-to-date on your progress?', answer: 'We will post regular updates on Twitter @grassroots_team'},
{description: 'I have more questions. Can you please help me?', answer: 'Sure. Just email us at grassroots@vaccine-ontario.ca'}
];

View File

@ -6,12 +6,15 @@ import Form from '../components/Form';
import Card from '../components/Card';
import Page from '../components/Page';
import Alert from '../components/Alert';
import { useEffect, useState } from 'react';
import React, { useEffect, useState } from 'react';
import { isIOS, isSafari, isAndroid} from 'react-device-detect';
import usePassCount from "../src/hooks/use_pass_count";
import Link from 'next/link'
function Index(): JSX.Element {
const { t } = useTranslation(['common', 'index', 'errors']);
const passCount = usePassCount();
const displayPassCount = (passCount? `${passCount} receipts have been processed successfully to date!` : '');
const [warningMessages, _setWarningMessages] = useState<Array<string>>([]);
@ -24,9 +27,13 @@ function Index(): JSX.Element {
const deleteWarningMessage = (message: string) => _setWarningMessages(warningMessages.filter(item => item !== message));
// useEffect(() => {
// if (!isSafari) setWarningMessage("iPhone users, only Safari is supported at the moment. Please switch to Safari to prevent any unexpected errors.")
// })
useEffect(() => {
if (isIOS && !isSafari) setWarningMessage("iPhone users, only Safari is supported at the moment. Please switch to Safari to prevent any unexpected errors.")
else if (!isIOS) {
setWarningMessage('Only Safari on iOS is officially supported for Apple Wallet import at the moment - ' +
'for other platforms, please ensure you have an application which can open Apple Wallet .pkpass files');
}
}, []);
// If you previously created a vaccination receipt before Sept. 23rd and need to add your date of birth on your vaccination receipt, please reimport your Ministry of Health official vaccination receipt again below and the date of birth will now be visible on the created receipt
@ -62,19 +69,27 @@ function Index(): JSX.Element {
<Page content={
<div className="space-y-5">
{warningMessages.map((message, i) =>
<Alert message={message} key={'error-' + i} type="error" onClose={() => deleteWarningMessage(message)} />
<Alert message={message} key={'error-' + i} type="warning" onClose={() => deleteWarningMessage(message)} />
)}
<Card content={
<div><p>{t('common:subtitle')}</p><br /><p>{t('common:subtitle2')}</p><br />
<b>Sept 25 evening updates</b> - Improvements:
<b>{displayPassCount}</b><br/><br/>
Oct 1 morning update:
<br />
<br />
<ul className="list-decimal list-outside" style={{ marginLeft: '20px' }}>
<li>Better support the use of .pkpass files on non-iOS platforms (thx samuelyeungkc)</li>
<li>Improved multiple passes handling</li>
<li>Added FAQ on how critical data (name & date of birth) is protected and they stay private to you.</li>
<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 />
<p>{t('common:continueSpirit')}</p></div>
<p>{t('common:continueSpirit')}</p>
<br />
<Link href="https://www.youtube.com/watch?v=AIrG5Qbjptg">
<a className="underline" target="_blank">
Click here for a video demo
</a>
</Link>&nbsp;
</div>
}/>
<Form/>
</div>

View File

@ -1,9 +1,9 @@
title: Vaccination Receipt to Wallet
subtitle: This utility (created by volunteers) converts your vaccination receipt from Ontario Ministry of Health to an Apple Wallet pass for easy access in the interim.
subtitle2: Once Ontario's official QR code is released on Oct 22, you will be able to update your Apple Wallet pass by visiting this site again.
subtitle2: Once Ontario's official QR code is available on Oct 22, you will be able to update your Apple Wallet pass by visiting this site again.
update1Date: Sep 23 Updates
update1: Thanks so much for all the encouragements and suggestions to make this better. We plan to keep enhancing this to help more Canadians. Stay tuned!
continueSpirit: Continuing the spirit of ❤️ @VaxHuntersCanada ❤️. 820K receipts processed already, thanks for keeping the community safe!
continueSpirit: Continuing the spirit of ❤️ @VaxHuntersCanada ❤️.
privacyPolicy: Privacy Policy
donate: Sponsor
gitHub: GitHub

View File

@ -4,7 +4,7 @@ selectCertificate: Select vaccination receipt (PDF)
selectCertificateDescription: |
Press "Select File", "Browse..." and select the PDF file you have saved in Step 1.
selectCertificateReminder: |
Reminder : Receipts directly downloaded from the provincial web site is required. Emailed copies are not digitally signed and cannot be added to Apple Wallet.
Reminder : Receipts directly downloaded from the provincial web site is required.
#stopCamera: Stop Camera
#startCamera: Start Camera
openFile: Select File
@ -13,7 +13,10 @@ downloadReceipt: Download official receipt from Ontario Ministry of Health
visit: Visit
ontarioHealth: Ontario Ministry of Health
gotoOntarioHealth: Go to Ontario Ministry of Health
downloadSignedPDF: and enter your information to display your official vaccination receipt. Press the Share Icon at the bottom, "Save As Files" to store it onto your iPhone. If you have completed this step before, you can proceed to the next step to prevent downloading the same file multiple times.
downloadSignedPDF: and enter your information to display your official vaccination receipt. Press the Share Icon at the bottom, "Save As Files" to store it onto your iPhone.
reminderNotToRepeat: If you have completed this step before, simply proceed to Step 2.
formatChange: After the recent vaccination receipt formatting change, both doses are included in the same file. Please select which dose you which dose you want to save.
saveMultiple: To save multiple receipts, please select the first one you want to save and click the Wallet or Photo button below, then change which dose is selected here and push the button again to generate another Wallet or Photo for another dose.
pickColor: Pick a Color
pickColorDescription: Pick a background color for your pass.
colorWhite: white

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;
}

View File

@ -0,0 +1,35 @@
import React, { useEffect, useState } from 'react';
export default function usePassCount() {
const [passCount, setPassCount] = useState<string>('');
const hitcountHost = 'https://stats.vaccine-ontario.ca';
useEffect(() => {
if (passCount.length == 0) {
getPassCount();
}
}, []);
const getPassCount = async () => {
const hitCount = await getHitCount();
setPassCount(hitCount);
};
async function getHitCount() {
try {
const request = `${hitcountHost}/nocount?url=pass.vaccine-ontario.ca`;
let response = await fetch(request);
const counter = await response.text();
return Promise.resolve(parseInt(counter, 10).toLocaleString());
} catch (e) {
console.error(e);
return Promise.reject(e);
}
}
return passCount;
};

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;
@ -81,71 +65,16 @@ export class PassData {
return await response.arrayBuffer()
}
static async generatePass(payloadBody: PayloadBody): Promise<Buffer> {
static async generatePass(payloadBody: PayloadBody, numDose: number): Promise<Buffer> {
// Create Payload
try {
const payload: Payload = new Payload(payloadBody);
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,9 +1,13 @@
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) {};
}
export interface HashTable<T> {
[key: string]: T;
}
enum TextAlignment {
right = 'PKTextAlignmentRight',
@ -28,12 +32,12 @@ export interface PassDictionary {
export interface PayloadBody {
// color: COLORS;
rawData: string;
receipt: Receipt;
receipts: HashTable<Receipt>;
}
export class Payload {
receipt: Receipt;
receipts: HashTable<Receipt>;
rawData: string;
backgroundColor: string;
labelColor: string;
@ -43,60 +47,116 @@ export class Payload {
serialNumber: string;
generic: PassDictionary;
constructor(body: PayloadBody) {
constructor(body: PayloadBody, numDose: number) {
// Get name and date of birth information
const name = body.receipt.name;
const dateOfBirth = body.receipt.dateOfBirth;
const vaccineName = body.receipt.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'))
vaccineNameProper = 'Pfizer (Comirnaty)'
if (vaccineName.includes('MODERNA'))
vaccineNameProper = 'Moderna (SpikeVax)'
// vaccineNameProper = 'Pfizer (Comirnaty)'
vaccineNameProper = 'Moderna (SpikeVax)'
if (vaccineName.includes('ASTRAZENECA'))
if (vaccineName.includes('ASTRAZENECA') || vaccineName.includes('COVISHIELD'))
vaccineNameProper = 'AstraZeneca (Vaxzevria)'
let doseVaccine = "#" + String(body.receipt.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.receipt.organization
},
value: receipt.organization
},
{
key: "dov",
label: "Date",
value: body.receipt.vaccinationDate,
// textAlignment: TextAlignment.right
value: receipt.vaccinationDate,
}
],
auxiliaryFields: [
{
);
if (generic.auxiliaryFields.length == 0) {
generic.auxiliaryFields.push(
{
key: "name",
label: "Name",
value: name
@ -105,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.receipt;
this.rawData = body.rawData;
if (body.receipt.numDoses > 1 || body.receipt.vaccineName.toLowerCase().includes('janssen') || body.receipt.vaccineName.toLowerCase().includes('johnson') || body.receipt.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): Promise<Blob> {
static async generatePass(payloadBody: PayloadBody, numDose: number): Promise<Blob> {
// Create Payload
try {
const payload: Payload = new Payload(payloadBody);
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'))
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} from "./payload";
import {PayloadBody, Receipt, HashTable} from "./payload";
import * as PdfJS from 'pdfjs-dist/legacy/build/pdf'
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 receipt: Receipt;
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':
receipt = 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 {
receipt: receipt,
receipts: receipts,
rawData: rawData
}
}
async function loadPDF(signedPdfBuffer : ArrayBuffer): Promise<any> {
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<any> {
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,61 +148,126 @@ async function loadPDF(signedPdfBuffer : ArrayBuffer): Promise<any> {
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);
}
}
async function getPdfDetails(fileBuffer: ArrayBuffer): Promise<Receipt> {
async function getPdfDetails(fileBuffer: ArrayBuffer): Promise<HashTable<Receipt>> {
try {
const typedArray = new Uint8Array(fileBuffer);
let loadingTask = PdfJS.getDocument(typedArray);
const pdfDocument = await loadingTask.promise;
// Load last PDF page
const pageNumber = pdfDocument.numPages;
// Load all dose numbers
const { numPages } = pdfDocument;
const receiptObj = {};
const pdfPage = await pdfDocument.getPage(pageNumber);
const content = await pdfPage.getTextContent();
const numItems = content.items.length;
let name, vaccinationDate, vaccineName, dateOfBirth, numDoses, organization;
for (let i = 0; i < numItems; i++) {
let item = content.items[i] as TextItem;
const value = item.str;
if (value.includes('Name / Nom'))
name = (content.items[i+1] as TextItem).str;
if (value.includes('Date:')) {
vaccinationDate = (content.items[i+1] as TextItem).str;
vaccinationDate = vaccinationDate.split(',')[0];
for (let pages = 1; pages <= numPages; pages++){
const pdfPage = await pdfDocument.getPage(pages);
const content = await pdfPage.getTextContent();
const numItems = content.items.length;
let name, vaccinationDate, vaccineName, dateOfBirth, numDoses, organization;
for (let i = 0; i < numItems; i++) {
let item = content.items[i] as TextItem;
const value = item.str;
if (value.includes('Name / Nom'))
name = (content.items[i+1] as TextItem).str;
if (value.includes('Date:')) {
vaccinationDate = (content.items[i+1] as TextItem).str;
vaccinationDate = vaccinationDate.split(',')[0];
}
if (value.includes('Product name')) {
vaccineName = (content.items[i+1] as TextItem).str;
}
if (value.includes('Date of birth'))
dateOfBirth = (content.items[i+1] as TextItem).str;
if (value.includes('Authorized organization'))
organization = (content.items[i+1] as TextItem).str;
if (value.includes('You have received'))
numDoses = Number(value.split(' ')[3]);
}
if (value.includes('Product name')) {
vaccineName = (content.items[i+1] as TextItem).str;
vaccineName = vaccineName.split(' ')[0];
}
if (value.includes('Date of birth'))
dateOfBirth = (content.items[i+1] as TextItem).str;
if (value.includes('Authorized organization'))
organization = (content.items[i+1] as TextItem).str;
if (value.includes('You have received'))
numDoses = Number(value.split(' ')[3]);
receiptObj[numDoses] = new Receipt(name, vaccinationDate, vaccineName, dateOfBirth, numDoses, organization);
console.log(receiptObj[numDoses]);
}
const receipt = new Receipt(name, vaccinationDate, vaccineName, dateOfBirth, numDoses, organization);
return Promise.resolve(receipt);
return Promise.resolve(receiptObj);
} catch (e) {
Sentry.captureException(e);
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

@ -3,12 +3,14 @@ import { Integrations } from '@sentry/tracing';
export const initSentry = () => {
SentryModule.init({
release: 'grassroots_covidpass@1.9.8', // App version. Needs to be manually updated as we go unless we make the build smarter
dsn: 'https://51370d7af0994761b465bc148129c1de@o997324.ingest.sentry.io/5955697',
release: 'grassroots_covidpass@1.9.12', // App version. Needs to be manually updated as we go unless we make the build smarter
dsn: 'https://7120dcf8548c4c5cb148cdde2ed6a778@o1015766.ingest.sentry.io/5981424',
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