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

270 lines
7.3 KiB
JavaScript

import './nacl.min.js'
import './zxing-browser.min.js'
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-title"></div>
<div class="qrcode-reader--popup-content"></div>
<button class="qrcode-reader--close-popup-button">${translate('close')}</button>
</div>
`)
const validityTemplate = template(`
<div class="qrcode-reader--validity">
<div class="qrcode-reader--validity-label">${translate('from')} :</div>
<div>{validityStart}</div>
<div>${translate('to')} :</div>
<div>{validityEnd}</div>
</div>
`)
const dataTemplate = template(`
<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>
`)
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
#popupContent
#popupTitle
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.#popupContent = this.querySelector('.qrcode-reader--popup-content')
this.#popupTitle = this.querySelector('.qrcode-reader--popup-title')
const closePopupButton = this.querySelector('.qrcode-reader--close-popup-button')
closePopupButton.addEventListener('click', () => {
this.#popup.classList.add('closed')
})
const fullScreenButton = this.querySelector('.qrcode-reader--fullscreen-button')
fullScreenButton.addEventListener('click', () => {
this.#toggleFullScreen()
})
}
connectedCallback () {
if (!this.#supported()) {
return
}
this.#startScan()
}
async #startScan () {
const codeReader = new window.ZXingBrowser.BrowserQRCodeReader()
const videoElement = this.querySelector('.qrcode-reader--video')
await codeReader.decodeFromVideoDevice(undefined, videoElement, (result) => {
if (result) {
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
}
#showResult (qrCodeContent) {
this.#popup.classList.remove('error')
this.#popup.classList.remove('closed')
let signed
try {
signed = decodeBase45(qrCodeContent)
} catch (error) {
this.#showError(translate('invalid_qrcode'))
return
}
const opened = window.nacl.sign.open(signed, this.#verifyKey)
if (opened == null) {
this.#showError(translate('invalid_signature'))
return
}
this.#popupContent.innerHTML = ''
const decoder = new TextDecoder('utf-8')
const decoded = decoder.decode(opened)
const data = decodeMimeLike(decoded)
delete data.uuid
const validityStart = new Date(parseFloat(data.validity_start) * 1000)
delete data.validity_start
const validityEnd = new Date(parseFloat(data.validity_end) * 1000)
delete data.validity_end
const now = new Date()
if (now.getTime() < validityStart.getTime()) {
this.#popupTitle.innerText = translate('not_yet_valid')
this.#popup.classList.add('error')
} else if (now.getTime() > validityEnd.getTime()) {
this.#popupTitle.innerText = translate('expired')
this.#popup.classList.add('error')
} else {
this.#popupTitle.innerText = translate('valid')
}
const validityElement = validityTemplate.cloneNode(true)
validityElement.innerHTML = validityElement.innerHTML
.replace('{validityStart}', validityStart.toLocaleString())
.replace('{validityEnd}', validityEnd.toLocaleString())
this.#popupContent.append(validityElement.content)
const dataElement = dataTemplate.cloneNode(true)
const dataItems = dataElement.content.querySelector('.qrcode-reader--data-items')
for (const [key, value] of Object.entries(data)) {
const dataItem = dataItemTemplate.cloneNode(true)
dataItem.innerHTML = dataItem.innerHTML.replace('{label}', key).replace('{value}', value)
dataItems.append(dataItem.content)
}
this.#popupContent.append(dataElement.content)
}
#showError (message) {
this.#popup.classList.remove('closed')
this.#popup.classList.add('error')
this.#popupTitle.innerText = translate('invalid_title')
this.#popupContent.innerText = message
}
#toggleFullScreen () {
if (document.fullscreenElement) {
document.exitFullscreen()
this.classList.remove('fullscreen')
} else {
this.requestFullscreen()
this.classList.add('fullscreen')
}
}
}
window.customElements.define('qrcode-reader', QRCodeReader)