270 lines
7.3 KiB
JavaScript
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} : </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)
|