551 lines
15 KiB
JavaScript
551 lines
15 KiB
JavaScript
import './nacl.min.js'
|
|
import './zxing-browser.min.js'
|
|
|
|
/* c8 ignore start */
|
|
// https://github.com/zxing-js/browser/issues/72
|
|
if (window.ZXingBrowser) {
|
|
const patchedMediaStreamIsTorchCompatible = window.ZXingBrowser.BrowserCodeReader.mediaStreamIsTorchCompatible
|
|
window.ZXingBrowser.BrowserCodeReader.mediaStreamIsTorchCompatible = (track) => {
|
|
return track.getCapabilities && patchedMediaStreamIsTorchCompatible(track)
|
|
}
|
|
}
|
|
/* c8 ignore stop */
|
|
|
|
const translations = (() => {
|
|
const i18nElement = window.document.getElementById('qrcode-reader-i18n')
|
|
if (i18nElement) {
|
|
return JSON.parse(i18nElement.innerHTML)
|
|
}
|
|
return {}
|
|
})()
|
|
|
|
function translate (key) { return translations[key] || key }
|
|
|
|
function template (innerHTML) {
|
|
const templateElement = document.createElement('template')
|
|
templateElement.innerHTML = innerHTML
|
|
return templateElement
|
|
}
|
|
|
|
const notSupportedTemplate = template(`<p>${translate('not_supported')}</p>`)
|
|
|
|
const readerTemplate = template(`
|
|
<div class="qrcode-reader--video-wrapper">
|
|
<video class="qrcode-reader--video"></video>
|
|
<div class="qrcode-reader--fullscreen-button">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
viewBox="0 0 24 24"
|
|
class="qrcode-reader--enter-fullscreen-icon">
|
|
<path d="M8 3V5H4V9H2V3H8ZM2 21V15H4V19H8V21H2ZM22 21H16V19H20V15H22V21ZM22 9H20V5H16V3H22V9Z"></path>
|
|
</svg>
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="qrcode-reader--exit-fullscreen-icon">
|
|
<path d="M18 7H22V9H16V3H18V7ZM8 9H2V7H6V3H8V9ZM18 17V21H16V15H22V17H18ZM8 15V21H6V17H2V15H8Z"></path>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
<div class="qrcode-reader--popup closed">
|
|
<div class="qrcode-reader--popup-errors"></div>
|
|
<qrcode-reader-validity></qrcode-reader-validity>
|
|
<qrcode-reader-data></qrcode-reader-data>
|
|
<qrcode-reader-tally></qrcode-reader-tally>
|
|
<button class="qrcode-reader--close-popup-button">${translate('close')}</button>
|
|
</div>
|
|
`)
|
|
|
|
function decodeMimeLike (value) {
|
|
const chunks = value.split('\n')
|
|
const data = {}
|
|
let k = null
|
|
let v = null
|
|
|
|
for (let i = 0; i < chunks.length; i++) {
|
|
const line = chunks[i]
|
|
if (line.startsWith(' ')) {
|
|
if (k !== null) {
|
|
v += '\n' + line.slice(1)
|
|
}
|
|
} else {
|
|
if (k !== null) {
|
|
data[k] = v
|
|
k = null
|
|
v = null
|
|
}
|
|
if (line.indexOf(': ') !== -1) {
|
|
const parts = line.split(': ', 2)
|
|
k = parts[0]
|
|
v = parts[1]
|
|
}
|
|
}
|
|
}
|
|
|
|
if (k !== null) {
|
|
data[k] = v
|
|
}
|
|
|
|
return data
|
|
}
|
|
|
|
function divmod (a, b) {
|
|
let remainder = a
|
|
let quotient = 0
|
|
if (a >= b) {
|
|
remainder = a % b
|
|
quotient = (a - remainder) / b
|
|
}
|
|
return [quotient, remainder]
|
|
}
|
|
|
|
const BASE45_CHARSET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:'
|
|
|
|
function decodeBase45 (str) {
|
|
const output = []
|
|
const buf = []
|
|
|
|
for (let i = 0, length = str.length; i < length; i++) {
|
|
const j = BASE45_CHARSET.indexOf(str[i])
|
|
if (j < 0) { throw new Error('Base45 decode: unknown character') }
|
|
buf.push(j)
|
|
}
|
|
|
|
for (let i = 0, length = buf.length; i < length; i += 3) {
|
|
const x = buf[i] + buf[i + 1] * 45
|
|
if (length - i >= 3) {
|
|
const [d, c] = divmod(x + buf[i + 2] * 45 * 45, 256)
|
|
output.push(d)
|
|
output.push(c)
|
|
} else {
|
|
output.push(x)
|
|
}
|
|
}
|
|
return new Uint8Array(output)
|
|
}
|
|
|
|
class QRCodeReader extends window.HTMLElement {
|
|
#popup
|
|
#errors
|
|
#currentCertificate
|
|
#validity
|
|
#data
|
|
#tally
|
|
|
|
constructor () {
|
|
super()
|
|
|
|
if (!this.#supported()) {
|
|
this.appendChild(notSupportedTemplate.content.cloneNode(true))
|
|
return
|
|
}
|
|
|
|
this.appendChild(readerTemplate.content.cloneNode(true))
|
|
|
|
this.#popup = this.querySelector('.qrcode-reader--popup')
|
|
this.#errors = this.querySelector('.qrcode-reader--popup-errors')
|
|
this.#validity = this.querySelector('qrcode-reader-validity')
|
|
this.#data = this.querySelector('qrcode-reader-data')
|
|
this.#tally = this.querySelector('qrcode-reader-tally')
|
|
|
|
const closePopupButton = this.querySelector('.qrcode-reader--close-popup-button')
|
|
closePopupButton.addEventListener('click', () => {
|
|
this.#currentCertificate = undefined
|
|
this.#popup.classList.add('closed')
|
|
})
|
|
|
|
const fullScreenButton = this.querySelector('.qrcode-reader--fullscreen-button')
|
|
fullScreenButton.addEventListener('click', () => {
|
|
this.#toggleFullScreen()
|
|
})
|
|
|
|
this.addEventListener('fullscreenchange', () => {
|
|
this.#onFullScreenChanged()
|
|
})
|
|
}
|
|
|
|
connectedCallback () {
|
|
if (!this.#supported()) {
|
|
return
|
|
}
|
|
|
|
if(this.getAttribute('tally-url') === null) {
|
|
this.#tally.setAttribute('hidden', true)
|
|
}
|
|
|
|
this.#startScan()
|
|
}
|
|
|
|
async #startScan () {
|
|
const codeReader = new window.ZXingBrowser.BrowserQRCodeReader()
|
|
const videoElement = this.querySelector('.qrcode-reader--video')
|
|
await codeReader.decodeFromVideoDevice(undefined, videoElement, async (result) => {
|
|
if (result) {
|
|
await this.#showResult(result.text)
|
|
}
|
|
})
|
|
}
|
|
|
|
get #verifyKey () {
|
|
const hexKey = this.getAttribute('verify-key')
|
|
return new Uint8Array(hexKey.match(/[\da-f]{2}/gi).map(h => parseInt(h, 16)))
|
|
}
|
|
|
|
#supported () {
|
|
return !!navigator.mediaDevices
|
|
}
|
|
|
|
async #showResult (qrCodeContent) {
|
|
this.#popup.classList.remove('error')
|
|
this.#popup.classList.remove('closed')
|
|
this.#popup.classList.remove('invalid-qrcode')
|
|
this.#errors.innerHTML = ''
|
|
|
|
let signed
|
|
try {
|
|
signed = decodeBase45(qrCodeContent)
|
|
} catch (error) {
|
|
this.#showError(translate('invalid_qrcode'))
|
|
this.#popup.classList.add('invalid-qrcode')
|
|
return
|
|
}
|
|
|
|
const opened = window.nacl.sign.open(signed, this.#verifyKey)
|
|
if (opened == null) {
|
|
this.#showError(translate('invalid_signature'))
|
|
this.#popup.classList.add('invalid-qrcode')
|
|
return
|
|
}
|
|
|
|
const decoder = new TextDecoder('utf-8')
|
|
const decoded = decoder.decode(opened)
|
|
const data = decodeMimeLike(decoded)
|
|
const certificateUUID = data.uuid
|
|
|
|
if(certificateUUID && certificateUUID === this.#currentCertificate) {
|
|
return
|
|
}
|
|
|
|
this.#currentCertificate = certificateUUID
|
|
|
|
delete data.uuid
|
|
|
|
const validityStart = data.validity_start
|
|
delete data.validity_start
|
|
|
|
const validityEnd = data.validity_end
|
|
delete data.validity_end
|
|
|
|
this.#validity.refresh(validityStart, validityEnd)
|
|
|
|
await Promise.all([
|
|
this.#data.refresh(data, this.getAttribute('metadata-url'), certificateUUID),
|
|
this.#tally.refresh(this.getAttribute('tally-url'), certificateUUID)
|
|
])
|
|
|
|
if(this.querySelector('.qrcode-reader-section.error')) {
|
|
this.#popup.classList.add('error')
|
|
}
|
|
}
|
|
|
|
#showError (message) {
|
|
this.#popup.classList.remove('closed')
|
|
this.#popup.classList.add('error')
|
|
this.#errors.innerText = message
|
|
}
|
|
|
|
#toggleFullScreen () {
|
|
if (document.fullscreenElement) {
|
|
document.exitFullscreen()
|
|
} else {
|
|
this.requestFullscreen()
|
|
}
|
|
}
|
|
|
|
#onFullScreenChanged () {
|
|
if (document.fullscreenElement === this) {
|
|
this.classList.add('fullscreen')
|
|
} else {
|
|
this.classList.remove('fullscreen')
|
|
}
|
|
}
|
|
}
|
|
|
|
window.customElements.define('qrcode-reader', QRCodeReader)
|
|
|
|
const sectionTemplate = template(`
|
|
<div class="qrcode-reader-section">
|
|
<div class="qrcode-reader-section--header">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
viewBox="0 0 24 24"
|
|
width="16"
|
|
height="16"
|
|
class="qrcode-reader-section--spinner">
|
|
<path d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z" opacity=".25"/>
|
|
<path
|
|
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
|
|
class="qrcode-reader-section--spinner-animation"/>
|
|
</svg>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
viewBox="0 0 24 24"
|
|
width="16"
|
|
height="16"
|
|
class="qrcode-reader-section--ok-icon">
|
|
>
|
|
<path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM11.0026 16L6.75999 11.7574L8.17421 10.3431L11.0026 13.1716L16.6595 7.51472L18.0737 8.92893L11.0026 16Z"></path>
|
|
</svg>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 24 24"
|
|
class="qrcode-reader-section--error-icon">
|
|
>
|
|
<path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM11 15H13V17H11V15ZM11 7H13V13H11V7Z"></path>
|
|
</svg>
|
|
<div class="qrcode-reader-section--title"></div>
|
|
</div>
|
|
<div class="qrcode-reader-section--errors">
|
|
</div>
|
|
<div class="qrcode-reader-section--content">
|
|
</div>
|
|
</div>
|
|
`)
|
|
|
|
|
|
class QRCodeReaderSection extends HTMLElement {
|
|
#title
|
|
#content
|
|
#errors
|
|
#wrapper
|
|
|
|
connectedCallback() {
|
|
this.appendChild(sectionTemplate.content.cloneNode(true))
|
|
|
|
this.#wrapper = this.querySelector('.qrcode-reader-section')
|
|
this.#title = this.querySelector('.qrcode-reader-section--title')
|
|
this.#content = this.querySelector('.qrcode-reader-section--content')
|
|
this.#errors = this.querySelector('.qrcode-reader-section--errors')
|
|
}
|
|
|
|
get contentElement() { return this.#content }
|
|
|
|
setTitle(title) {
|
|
this.#title.innerText = title
|
|
}
|
|
|
|
setContent(element) {
|
|
this.#content.innerHTML = ''
|
|
this.#content.appendChild(element)
|
|
}
|
|
|
|
showError(message) {
|
|
this.#wrapper.classList.add('error')
|
|
this.#errors.innerHTML = message
|
|
}
|
|
|
|
reset() {
|
|
this.#wrapper.classList.remove('error')
|
|
this.#content.innerHTML = ''
|
|
}
|
|
|
|
async load(callback) {
|
|
this.#wrapper.classList.add('loading')
|
|
try {
|
|
await callback()
|
|
}
|
|
finally {
|
|
this.#wrapper.classList.remove('loading')
|
|
}
|
|
}
|
|
}
|
|
|
|
const validityTemplate = template(`
|
|
<div class="qrcode-reader-validity--label">${translate('from')} :</div>
|
|
<div>{validityStart}</div>
|
|
<div class="qrcode-reader-validity--label">${translate('to')} :</div>
|
|
<div>{validityEnd}</div>
|
|
`)
|
|
|
|
class QRCodeReaderValidity extends QRCodeReaderSection {
|
|
connectedCallback() {
|
|
super.connectedCallback()
|
|
this.setTitle(translate('validity_section_title'))
|
|
}
|
|
|
|
refresh(validityStart, validityEnd) {
|
|
this.reset()
|
|
validityStart = validityStart && new Date(parseFloat(validityStart) * 1000)
|
|
validityEnd = validityEnd && new Date(parseFloat(validityEnd) * 1000)
|
|
|
|
const now = new Date()
|
|
|
|
if (validityStart && now.getTime() < validityStart.getTime()) {
|
|
this.showError(translate('not_yet_valid'))
|
|
} else if (validityEnd && now.getTime() > validityEnd.getTime()) {
|
|
this.showError(translate('expired'))
|
|
}
|
|
|
|
const validityElement = validityTemplate.cloneNode(true)
|
|
if (validityStart) {
|
|
validityElement.innerHTML = validityElement.innerHTML.replace('{validityStart}', validityStart.toLocaleString())
|
|
} else {
|
|
validityElement.innerHTML = validityElement.innerHTML.replace('{validityStart}', translate('always'))
|
|
}
|
|
|
|
if (validityStart) {
|
|
validityElement.innerHTML = validityElement.innerHTML.replace('{validityEnd}', validityEnd.toLocaleString())
|
|
} else {
|
|
validityElement.innerHTML = validityElement.innerHTML.replace('{validityEnd}', translate('never'))
|
|
}
|
|
|
|
this.setContent(validityElement.content)
|
|
}
|
|
}
|
|
|
|
window.customElements.define('qrcode-reader-validity', QRCodeReaderValidity)
|
|
|
|
|
|
const dataTemplate = template(`
|
|
<button class="qrcode-reader-data--retry-button">${translate('retry')}</button>
|
|
<div class="qrcode-reader-data--items"></div>
|
|
`)
|
|
|
|
const dataItemTemplate = template(`
|
|
<span class="qrcode-reader-data--item-label">{label} : </span>
|
|
<span class="qrcode-reader-data--item-value">{value}</span>
|
|
`)
|
|
|
|
class QRCodeReaderData extends QRCodeReaderSection {
|
|
connectedCallback() {
|
|
super.connectedCallback()
|
|
this.setTitle(translate('data_section_title'))
|
|
}
|
|
|
|
async refresh(data, metadataUrl, certificateUUID) {
|
|
this.reset()
|
|
const contentElement = dataTemplate.content.cloneNode(true)
|
|
const itemsElement = contentElement.querySelector('.qrcode-reader-data--items')
|
|
const retryButton = contentElement.querySelector('.qrcode-reader-data--retry-button')
|
|
|
|
retryButton.addEventListener('click', () => {
|
|
this.refresh(data, metadataUrl, certificateUUID)
|
|
})
|
|
|
|
this.setContent(contentElement)
|
|
|
|
for (const [key, value] of Object.entries(data)) {
|
|
this.#addItem(itemsElement, key, value)
|
|
}
|
|
|
|
await this.load(async () => {
|
|
await this.#showMetadata(itemsElement, metadataUrl, certificateUUID)
|
|
})
|
|
}
|
|
|
|
#addItem(itemsElement, label, value) {
|
|
const dataItem = dataItemTemplate.cloneNode(true)
|
|
dataItem.innerHTML = dataItem.innerHTML.replace('{label}', label).replace('{value}', value)
|
|
itemsElement.append(dataItem.content)
|
|
}
|
|
|
|
async #showMetadata(itemsElement, metadataUrl, certificateUUID) {
|
|
if(metadataUrl === null) {
|
|
return
|
|
}
|
|
|
|
const metadata = await this.#fetchMetadata(metadataUrl, certificateUUID)
|
|
if(!metadata) {
|
|
return
|
|
}
|
|
|
|
for (const [key, value] of Object.entries(metadata)) {
|
|
this.#addItem(itemsElement, key, value)
|
|
}
|
|
}
|
|
|
|
async #fetchMetadata(metadataUrl, certificateUUID) {
|
|
const url = `${metadataUrl}?certificate=${certificateUUID}`
|
|
let metadata = undefined
|
|
try {
|
|
const response = await fetch(url)
|
|
const content = await response.json()
|
|
|
|
if(response.ok && content.err === 0) {
|
|
metadata = content.data
|
|
}
|
|
else {
|
|
this.showError(translate('metadata_api_error'))
|
|
}
|
|
} catch(err) {
|
|
if(err.name === 'NetworkError') {
|
|
this.showError(translate('metadata_network_error'))
|
|
}
|
|
else {
|
|
throw err
|
|
}
|
|
}
|
|
|
|
return metadata
|
|
}
|
|
}
|
|
|
|
window.customElements.define('qrcode-reader-data', QRCodeReaderData)
|
|
|
|
class QRCodeReaderTally extends QRCodeReaderSection {
|
|
connectedCallback() {
|
|
super.connectedCallback()
|
|
this.setTitle(translate('tally_section_title'))
|
|
}
|
|
|
|
async refresh(tallyUrl, certificateUUID) {
|
|
if(tallyUrl === null) {
|
|
return
|
|
}
|
|
|
|
this.reset()
|
|
|
|
await this.load(async () => await this.#showTally(tallyUrl, certificateUUID))
|
|
}
|
|
|
|
async #showTally(tallyUrl, certificateUUID) {
|
|
try {
|
|
const response = await fetch(
|
|
tallyUrl, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
events: [{
|
|
certificate: certificateUUID,
|
|
timestamp: Math.floor(Date.now() / 1000)}
|
|
]}
|
|
),
|
|
})
|
|
|
|
if(!response.ok) {
|
|
this.showError(translate('tally_api_error'))
|
|
}
|
|
else {
|
|
const json = await response.json()
|
|
|
|
if(json.data.stamps[certificateUUID] == 'duplicate') {
|
|
this.showError(translate('tally_status_already_seen'))
|
|
}
|
|
else {
|
|
this.contentElement.innerText = translate('tally_status_ok')
|
|
}
|
|
}
|
|
} catch(err) {
|
|
if(err.name == 'NetworkError') {
|
|
this.showError(translate('tally_network_error'))
|
|
}
|
|
else {
|
|
throw err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
window.customElements.define('qrcode-reader-tally', QRCodeReaderTally)
|