Merge pull request #19 from covidpass-org/dev

Added a build int QR-Code reader. Support upload QR-Codes as PNG
This commit is contained in:
Sören Busse 2021-06-30 03:25:38 +02:00 committed by GitHub
commit 55339efe6e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 894 additions and 4890 deletions

View File

@ -23,8 +23,7 @@ RUN addgroup -g 1001 -S nodejs
RUN adduser -S nextjs -u 1001
# You only need to copy next.config.js if you are NOT using the default configuration
COPY --from=builder /app/next.config.js ./
COPY --from=builder /app/server.js ./
# COPY --from=builder /app/next.config.js ./
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
COPY --from=builder /app/node_modules ./node_modules

View File

@ -4,8 +4,6 @@
Web app for adding EU COVID-19 Vaccination Certificates to your wallets
The API can be found [here](https://github.com/marvinsxtr/covidpass-api).
## Setup
```sh

View File

@ -1,9 +1,10 @@
export default Card
function Card({ heading, content, step }) {
function Card({heading, content, step}) {
return (
<div className="rounded-md p-6 bg-white dark:bg-gray-800 space-y-4">
{step &&
{
step &&
<div className="flex flex-row items-center">
<div className="rounded-full p-4 bg-green-600 h-5 w-5 flex items-center justify-center">
<p className="text-white text-lg font-bold">
@ -13,7 +14,8 @@ function Card({ heading, content, step }) {
<div className="ml-3 font-bold text-xl">
{heading}
</div>
</div>}
</div>
}
<div className="text-lg">
{content}
</div>

View File

@ -1,13 +1,12 @@
const PDFJS = require('pdfjs-dist')
PDFJS.GlobalWorkerOptions.workerSrc = `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${PDFJS.version}/pdf.worker.js`
import jsQR from "jsqr"
import {saveAs} from 'file-saver'
import {BrowserQRCodeReader} from '@zxing/browser'
import React, {useEffect, useRef, useState} from "react"
import {decodeData} from "../src/decode"
import {processPdf, processPng} from "../src/process"
import {createPass} from "../src/pass"
import Card from "../components/Card"
import Alert from "../components/Alert"
import jsQR from "jsqr";
export default Form
@ -27,7 +26,7 @@ function Form() {
})
}
const error = function(heading, message) {
const error = function (heading, message) {
const alert = document.getElementById('alert')
alert.setAttribute('style', null)
@ -37,62 +36,71 @@ function Form() {
document.getElementById('spin').style.display = 'none'
}
const processPdf = async function() {
const processFile = async function () {
console.log(qrCode)
console.log(file)
if (!qrCode && !file) {
error("Error", "Please capture a QR Code or select a file to scan");
return;
}
document.getElementById('spin').style.display = 'block'
const file = document.getElementById('pdf').files[0]
let rawData;
const result = await readFileAsync(file)
let typedArray = new Uint8Array(result)
if (file) {
let imageData
const fileBuffer = await readFileAsync(file)
const canvas = document.getElementById('canvas')
const ctx = canvas.getContext('2d')
switch (file.type) {
case 'application/pdf':
console.log('pdf')
imageData = await processPdf(fileBuffer)
break
case 'image/png':
console.log('png')
imageData = await processPng(fileBuffer)
break
default:
error('Error', 'Invalid file type')
return
}
let loadingTask = PDFJS.getDocument(typedArray)
await loadingTask.promise.then(async function (pdfDocument) {
const pdfPage = await pdfDocument.getPage(1)
const viewport = pdfPage.getViewport({ scale: 1 })
canvas.width = viewport.width
canvas.height = viewport.height
const renderTask = pdfPage.render({
canvasContext: ctx,
viewport,
})
return await renderTask.promise
})
let imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
let code = jsQR(imageData.data, imageData.width, imageData.height, {
inversionAttempts: 'dontInvert',
})
if (code) {
const rawData = code.data
rawData = code.data;
} else {
rawData = qrCode.getText()
}
if (rawData) {
let decoded
try {
decoded = decodeData(rawData)
} catch (error) {
error('Invalid QR code found', 'Make sure that you picked the correct PDF')
} catch (e) {
error('Invalid QR code found', 'Try another method to select your certificate')
return;
}
return {decoded: decoded, raw: rawData}
} else {
error('No QR code found', 'Try scanning the PDF again')
error('No QR code found', 'Try another method to select your certificate')
}
}
const addToWallet = async function(event) {
const addToWallet = async function (event) {
event.preventDefault()
let result
try {
result = await processPdf()
} catch {
error('Error:', 'Could not extract QR code data from PDF')
result = await processFile()
} catch (e) {
error('Error:', 'Could not extract QR code data from certificate')
}
if (typeof result === 'undefined') {
@ -123,26 +131,123 @@ function Form() {
}
}
const [isCameraOpen, setIsCameraOpen] = useState(false);
const [globalControls, setGlobalControls] = useState(undefined);
const [qrCode, setQrCode] = useState(undefined);
const [file, setFile] = useState(undefined);
const inputFile = useRef(undefined)
useEffect(() => {
if (inputFile && inputFile.current) {
inputFile.current.addEventListener('input', () => {
let selectedFile = inputFile.current.files[0];
if (selectedFile !== undefined) {
setQrCode(undefined);
setFile(selectedFile);
}
});
}
}, [inputFile])
async function showFileDialog() {
inputFile.current.click();
}
async function hideCameraView() {
if (globalControls !== undefined) {
globalControls.stop();
}
setIsCameraOpen(false);
}
async function showCameraView() {
const codeReader = new BrowserQRCodeReader();
// Needs to be called before any camera can be accessed
await BrowserQRCodeReader.listVideoInputDevices();
// Get preview Element to show camera stream
const previewElem = document.querySelector('#cameraPreview');
setGlobalControls(await codeReader.decodeFromVideoDevice(undefined, previewElem, (result, error, controls) => {
if (result !== undefined) {
setQrCode(result);
setFile(undefined);
controls.stop();
// Reset
setGlobalControls(undefined);
setIsCameraOpen(false);
}
}));
setIsCameraOpen(true);
}
return (
<div>
<form className="space-y-5" id="form" action="https://api.covidpass.marvinsextro.de/covid.pkpass" method="POST" onSubmit={(e) => addToWallet(e)}>
<form className="space-y-5" id="form" onSubmit={(e) => addToWallet(e)}>
<Card step={1} heading="Select Certificate" content={
<div className="space-y-5">
<p>
Please select the (scanned) certificate PDF page, which you received from your doctor, pharmacy, vaccination centre or online.
Please select the certificate screenshot or (scanned) PDF page, which you received from your
doctor, pharmacy, vaccination centre or online. Note that taking a picture does not work on
most devices yet.
</p>
<input
className="w-full"
type="file"
id="pdf"
accept="application/pdf"
required
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<button
type="button"
onClick={isCameraOpen ? hideCameraView : showCameraView}
className="focus:outline-none h-20 bg-gray-500 hover:bg-gray-700 text-white font-semibold rounded-md">
{isCameraOpen ? "Stop Camera" : "Start Camera"}
</button>
<button
type="button"
onClick={showFileDialog}
className="focus:outline-none h-20 bg-gray-500 hover:bg-gray-700 text-white font-semibold rounded-md">
Open File (PDF, PNG)
</button>
</div>
} />
<video id="cameraPreview" className={`${isCameraOpen ? undefined : "hidden"} rounded-md w-full`}/>
<input type='file'
id='file'
accept="application/pdf,image/png"
ref={inputFile}
style={{display: 'none'}}
/>
{(qrCode || file) &&
<div className="flex items-center space-x-1">
<svg className="h-4 w-4 text-green-600" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 5l7 7-7 7"/>
</svg>
<span className="w-full">
{
qrCode && 'Found QR Code!'
}
{
file && file.name
}
</span>
</div>}
</div>
}/>
<Card step={2} heading="Pick a Color" content={
<div className="space-y-5">
<p>
Pick a background color for your pass.
</p>
<div className="relative inline-block w-full">
<select name="color" id="color" className="bg-gray-200 dark:bg-gray-900 focus:outline-none w-full h-10 pl-3 pr-6 text-base rounded-md appearance-none cursor-pointer">
<select name="color" id="color"
className="bg-gray-200 dark:bg-gray-900 focus:outline-none w-full h-10 pl-3 pr-6 text-base rounded-md appearance-none cursor-pointer">
<option value="white">white</option>
<option value="black">black</option>
<option value="grey">grey</option>
@ -153,38 +258,47 @@ function Form() {
<option value="teal">teal</option>
</select>
<div className="absolute inset-y-0 right-0 flex items-center px-2 pointer-events-none">
<svg className="w-5 h-5 fill-current" viewBox="0 0 20 20"><path d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" fillRule="evenodd"/></svg>
<svg className="w-5 h-5 fill-current" viewBox="0 0 20 20">
<path
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clipRule="evenodd" fillRule="evenodd"/>
</svg>
</div>
</div>
} />
</div>
}/>
<Card step={3} heading="Add to Wallet" content={
<div className="space-y-5">
<p>
Data privacy is of special importance when processing health-related data.
In order for you to make an informed decision, please read the <a href="/privacy">Privacy Policy</a>.
In order for you to make an informed decision, please read the <a href="/privacy">Privacy
Policy</a>.
</p>
<label htmlFor="privacy" className="flex flex-row space-x-4 items-center">
<input type="checkbox" id="privacy" value="privacy" required className="h-4 w-4" />
<input type="checkbox" id="privacy" value="privacy" required className="h-4 w-4"/>
<p>
I accept the <a href="/privacy" className="underline">Privacy Policy</a>
</p>
</label>
<div className="flex flex-row items-center justify-start">
<button id="download" type="download" className="focus:outline-none bg-green-600 py-2 px-3 text-white font-semibold rounded-md disabled:bg-gray-400">
<button id="download" type="download"
className="focus:outline-none bg-green-600 py-2 px-3 text-white font-semibold rounded-md disabled:bg-gray-400">
Add to Wallet
</button>
<div id="spin" style={{ "display": "none" }}>
<div id="spin" style={{"display": "none"}}>
<svg className="animate-spin h-5 w-5 ml-2" 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"/>
<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>
</div>
} />
}/>
</form>
<Alert />
<canvas id="canvas" style={{ display: "none" }} />
<Alert/>
<canvas id="canvas" style={{display: "none"}}/>
</div>
)
}

View File

@ -1,4 +1,3 @@
import Icon from '../public/favicon.svg'
import Link from 'next/link'
export default Logo
@ -6,8 +5,18 @@ export default Logo
function Logo() {
return (
<Link href="/">
<a className="flex flex-row items-center p-3 justify-center space-x-1" >
<Icon className="fill-current" />
<a className="flex flex-row items-center p-3 justify-center space-x-1">
<svg className="fill-current" xmlns="http://www.w3.org/2000/svg" enableBackground="new 0 0 24 24"
height="48px"
viewBox="0 0 24 24" width="48px" fill="#000000">
<g>
<path d="M0,0h24v24H0V0z" fill="none"/>
</g>
<g>
<path
d="M11.3,2.26l-6,2.25C4.52,4.81,4,5.55,4,6.39v4.71c0,5.05,3.41,9.76,8,10.91c4.59-1.15,8-5.86,8-10.91V6.39 c0-0.83-0.52-1.58-1.3-1.87l-6-2.25C12.25,2.09,11.75,2.09,11.3,2.26z M10.23,14.83l-2.12-2.12c-0.39-0.39-0.39-1.02,0-1.41l0,0 c0.39-0.39,1.02-0.39,1.41,0l1.41,1.41l3.54-3.54c0.39-0.39,1.02-0.39,1.41,0l0,0c0.39,0.39,0.39,1.02,0,1.41l-4.24,4.24 C11.26,15.22,10.62,15.22,10.23,14.83z"/>
</g>
</svg>
<h1 className="text-3xl font-bold">
CovidPass
</h1>

View File

@ -9,7 +9,7 @@ function Page({ content }) {
<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>
<title>CovidPass</title>
<link rel="icon" href="/favicon.png" />
<link rel="icon" href="/favicon.ico" />
</Head>
<div>
<main className="flex flex-col space-y-5">
@ -18,11 +18,11 @@ function Page({ content }) {
{content}
<footer>
<nav className="nav flex space-x-4 m-6 flex-row-reverse space-x-reverse text-md font-bold">
<Link href="/privacy"><a className="hover:underline" >Privacy Policy</a></Link>
<Link href="/imprint"><a className="hover:underline" >Imprint</a></Link>
<nav className="nav flex pt-4 flex-row space-x-4 justify-center text-md font-bold">
<a href="https://www.paypal.com/paypalme/msextro" className="hover:underline" >Donate</a>
<a href="https://github.com/marvinsxtr/covidpass" className="hover:underline" >GitHub</a>
<Link href="/privacy"><a className="hover:underline" >Privacy Policy</a></Link>
<Link href="/imprint"><a className="hover:underline" >Imprint</a></Link>
</nav>
</footer>
</main>

View File

@ -15,12 +15,12 @@ export default {
additionalLinkTags: [
{
rel: 'icon',
href: 'https://covidpass.marvinsextro.de/favicon.png',
href: 'https://covidpass.marvinsextro.de/favicon.ico',
},
{
rel: 'apple-touch-icon',
href: 'https://covidpass.marvinsextro.de/favicon.png',
sizes: '96x96'
href: 'https://covidpass.marvinsextro.de/apple-touch-icon.png',
sizes: '180x180'
},
]
}

View File

@ -1,9 +0,0 @@
module.exports = {
webpack(config) {
config.module.rules.push({
test: /\.svg$/,
use: ['@svgr/webpack'],
});
return config;
},
}

5110
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -4,27 +4,30 @@
"author": "Marvin Sextro <marvin.sextro@gmail.com>",
"private": true,
"scripts": {
"dev": "node server.js",
"dev": "next dev",
"build": "next build",
"start": "NODE_ENV=production node server.js"
"start": "next start"
},
"dependencies": {
"@zxing/browser": "^0.0.9",
"@zxing/library": "^0.18.6",
"base45-js": "^1.0.1",
"cbor-js": "^0.1.0",
"do-not-zip": "^1.0.0",
"file-saver": "^2.0.5",
"jpeg-js": "^0.4.3",
"jsqr": "^1.4.0",
"next": "latest",
"next-seo": "^4.26.0",
"node-fetch": "^2.6.1",
"pdfjs-dist": "^2.5.207",
"pngjs": "^6.0.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"webpack": "^5.0.0",
"worker-loader": "^3.0.7"
},
"devDependencies": {
"@svgr/webpack": "^5.5.0",
"autoprefixer": "^10.0.4",
"postcss": "^8.1.10",
"tailwindcss": "^2.1.1"

View File

@ -1,14 +1,13 @@
import Document, { Html, Head, Main, NextScript } from 'next/document'
import Document, {Html, Head, Main, NextScript} from 'next/document'
class CustomDocument extends Document {
render() {
return (
<Html lang="en">
<Head />
<Head/>
<body className="bg-gray-200 dark:bg-gray-900 text-gray-800 dark:text-white">
<Main />
<NextScript />
<Main/>
<NextScript/>
</body>
</Html>
)

View File

@ -17,10 +17,16 @@ export default function Home() {
images: [
{
url: 'https://covidpass.marvinsextro.de/thumbnail.png',
width: 611,
height: 318,
width: 1000,
height: 500,
alt: 'CovidPass: Add your EU Digital Covid Vaccination Certificates to your favorite wallet app.',
},
{
url: 'https://covidpass.marvinsextro.de/favicon.png',
width: 500,
height: 500,
alt: 'CovidPass',
},
],
site_name: 'CovidPass',
}}

View File

@ -13,7 +13,7 @@ export default function Privacy() {
<div className="px-4">
<ul className="list-disc">
<li>
The whole process of generating the pass file happens locally in your browser. For the signing step, ony a hashed representation of your data is sent to the server.
The whole process of generating the pass file happens locally in your browser. For the signing step, only a hashed representation of your data is sent to the server.
</li>
<li>
Your data is not stored beyond the active browser session and the site does not use cookies.
@ -34,7 +34,7 @@ export default function Privacy() {
By default, Apple Wallet passes are accessible from the lock screen. This can be changed in the <a href="https://support.apple.com/de-de/guide/iphone/iph9a2a69136/ios" className="underline">settings</a>.
</li>
<li>
The server provider processes data to provide this site. In order to better understand what measures they take to protect your data, please also read their <a href="https://www.hetzner.com/de/rechtliches/datenschutz/" className="underline">privacy policy</a> and the <a href="https://docs.hetzner.com/general/general-terms-and-conditions/data-privacy-faq/" className="underline">data privacy FAQ</a>
The server provider processes data to provide this site. In order to better understand what measures they take to protect your data, please also read their <a href="https://www.hetzner.com/de/rechtliches/datenschutz/" className="underline">privacy policy</a> and the <a href="https://docs.hetzner.com/general/general-terms-and-conditions/data-privacy-faq/" className="underline">data privacy FAQ</a>.
</li>
</ul>
</div>
@ -78,7 +78,7 @@ export default function Privacy() {
</p>
<div className="px-4">
<ul className="list-disc">
<li>Assembling the signed pass file out of the inclomplete file generated locally and the signature</li>
<li>Assembling the signed pass file out of the incomplete file generated locally and the signature</li>
<li>Saving the file on your device</li>
</ul>
</div>

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 858 B

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="48px" viewBox="0 0 24 24" width="48px" fill="#000000"><g><path d="M0,0h24v24H0V0z" fill="none"/></g><g><path d="M11.3,2.26l-6,2.25C4.52,4.81,4,5.55,4,6.39v4.71c0,5.05,3.41,9.76,8,10.91c4.59-1.15,8-5.86,8-10.91V6.39 c0-0.83-0.52-1.58-1.3-1.87l-6-2.25C12.25,2.09,11.75,2.09,11.3,2.26z M10.23,14.83l-2.12-2.12c-0.39-0.39-0.39-1.02,0-1.41l0,0 c0.39-0.39,1.02-0.39,1.41,0l1.41,1.41l3.54-3.54c0.39-0.39,1.02-0.39,1.41,0l0,0c0.39,0.39,0.39,1.02,0,1.41l-4.24,4.24 C11.26,15.22,10.62,15.22,10.23,14.83z"/></g></svg>

Before

Width:  |  Height:  |  Size: 588 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 52 KiB

View File

@ -1,25 +0,0 @@
const { createServer } = require('http')
const { parse } = require('url')
const next = require('next')
const dev = process.env.NODE_ENV !== 'production'
const app = next({ dev })
const handle = app.getRequestHandler()
app.prepare().then(() => {
createServer((req, res) => {
const parsedUrl = parse(req.url, true)
const { pathname, query } = parsedUrl
if (pathname === '/a') {
app.render(req, res, '/a', query)
} else if (pathname === '/b') {
app.render(req, res, '/b', query)
} else {
handle(req, res, parsedUrl)
}
}).listen(3000, (err) => {
if (err) throw err
console.log('> Ready on http://localhost:3000')
})
})

57
src/process.js Normal file
View File

@ -0,0 +1,57 @@
import {PNG} from 'pngjs'
import * as PdfJS from 'pdfjs-dist'
PdfJS.GlobalWorkerOptions.workerSrc = `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${PdfJS.version}/pdf.worker.js`
// Processes a pdf file and returns it as ImageData
export async function processPdf(file) {
// Array to store ImageData information
const typedArray = new Uint8Array(file)
// PDF scale, increase to read smaller QR Codes
const pdfScale = 2;
// Get the canvas and context to render PDF
const canvas = document.getElementById('canvas')
const ctx = canvas.getContext('2d')
let loadingTask = PdfJS.getDocument(typedArray)
await loadingTask.promise.then(async function (pdfDocument) {
// Load last PDF page
const pageNumber = pdfDocument.numPages;
const pdfPage = await pdfDocument.getPage(pageNumber)
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: ctx,
viewport,
})
return await renderTask.promise
})
// Return PDF Image Data
return ctx.getImageData(0, 0, canvas.width, canvas.height)
}
// Processes a PNG File and returns it as ImageData
export async function processPng(file) {
return new Promise(async (resolve, reject) => {
let png = new PNG({filterType: 4})
png.parse(file, (error, data) => {
if (error) {
reject()
}
resolve(data)
})
})
}