passerelle/passerelle/apps/qrcode/static/qrcode/js/qrcode-reader.js

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}&nbsp;:&nbsp;</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)