2021-09-05 09:58:19 +00:00
|
|
|
function bytesToImgData(bytes) {
|
|
|
|
const nPixel = Math.ceil(bytes.length / 3)
|
|
|
|
const side = Math.ceil(Math.sqrt(nPixel))
|
|
|
|
const u32 = new Uint32Array(side * side)
|
|
|
|
|
|
|
|
// out of bounds => 0
|
|
|
|
for (let i = 0, j = 0; i < nPixel; i++, j += 3) {
|
|
|
|
u32[i] = // 0xAABBGGRR
|
|
|
|
bytes[j + 0] << 0 | // R
|
|
|
|
bytes[j + 1] << 8 | // G
|
|
|
|
bytes[j + 2] << 16 | // B
|
|
|
|
0xff000000 // A (255)
|
|
|
|
}
|
|
|
|
u32.fill(0xff000000, nPixel)
|
|
|
|
|
|
|
|
const u8 = new Uint8ClampedArray(u32.buffer)
|
|
|
|
return new ImageData(u8, side, side)
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let hashExp
|
|
|
|
|
|
|
|
function canvasToBlob(canvas) {
|
|
|
|
return new Promise(resolve => {
|
|
|
|
canvas.toBlob(blob => {
|
|
|
|
resolve(blob)
|
|
|
|
})
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
let canvas
|
|
|
|
|
|
|
|
async function pack() {
|
|
|
|
const bytes = bundle()
|
|
|
|
const imgData = bytesToImgData(bytes)
|
|
|
|
console.log(imgData.width + '*' + imgData.height, 'bytes:', bytes.length)
|
|
|
|
|
|
|
|
hashExp = await sha256(imgData.data)
|
|
|
|
|
|
|
|
canvas = document.createElement('canvas')
|
|
|
|
canvas.width = imgData.width
|
|
|
|
canvas.height = imgData.height
|
|
|
|
|
|
|
|
const ctx = canvas.getContext('2d')
|
|
|
|
ctx.putImageData(imgData, 0, 0)
|
|
|
|
|
|
|
|
const blob = await canvasToBlob(canvas)
|
|
|
|
imgPreview.src = URL.createObjectURL(blob)
|
|
|
|
divPreview.hidden = false
|
|
|
|
|
|
|
|
const {width, height} = canvas
|
|
|
|
const size = blob.size.toLocaleString()
|
|
|
|
imgPreview.title = `${width}*${height} [${size} Bytes]`
|
|
|
|
}
|
|
|
|
|
|
|
|
let previewWin
|
|
|
|
|
|
|
|
self.onmessage = function(e) {
|
|
|
|
if (e.source === previewWin && e.data === 'GET_PREVIEW_DATA') {
|
|
|
|
const imgDataUrl = canvas.toDataURL()
|
|
|
|
previewWin.postMessage(imgDataUrl, '*')
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function getPreviewFile() {
|
|
|
|
if (confMap['index.html']) {
|
|
|
|
return ''
|
|
|
|
}
|
|
|
|
const files = Object.keys(confMap)
|
|
|
|
const html = files.find(v => v.endsWith('.html'))
|
|
|
|
if (html) {
|
|
|
|
return html
|
|
|
|
}
|
|
|
|
return files[0]
|
|
|
|
}
|
|
|
|
|
|
|
|
btnPreview.onclick = function() {
|
|
|
|
const rand = (Math.random() * 0xffffffff >>> 0).toString(36)
|
|
|
|
const site = `https://web2img-preview-${rand}.etherdream.com/`
|
|
|
|
|
|
|
|
previewWin = open(site + getPreviewFile())
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function verifyConf(str) {
|
|
|
|
const conf = parseJson(str)
|
|
|
|
if (!conf || typeof conf !== 'object') {
|
|
|
|
return 'invalid conf'
|
|
|
|
}
|
|
|
|
const pairs = Object.entries(conf)
|
|
|
|
if (pairs.length === 0) {
|
|
|
|
return 'empty conf'
|
|
|
|
}
|
|
|
|
for (const [path, headers] of pairs) {
|
|
|
|
const data = dataMap[path]
|
|
|
|
if (!data) {
|
|
|
|
return `file not found: ${path}`
|
|
|
|
}
|
|
|
|
if (typeof headers !== 'object') {
|
|
|
|
return `invalid header type: ${path}`
|
|
|
|
}
|
|
|
|
if (headers['content-length'] != data.length) {
|
|
|
|
headers['content-length'] = data.length
|
|
|
|
console.warn('fix content-length:', path)
|
|
|
|
}
|
|
|
|
try {
|
|
|
|
var res = new Response('', {headers})
|
|
|
|
} catch (err) {
|
|
|
|
return `invalid headers: ${path}`
|
|
|
|
}
|
|
|
|
for (const k in headers) {
|
|
|
|
if (!res.headers.has(k)) {
|
|
|
|
return `unsupported header: ${k} (${path})`
|
|
|
|
}
|
|
|
|
}
|
|
|
|
conf[path] = Object.fromEntries(res.headers)
|
|
|
|
}
|
|
|
|
confMap = conf
|
|
|
|
return ''
|
|
|
|
}
|
|
|
|
|
|
|
|
let lastConf
|
|
|
|
|
|
|
|
txtConf.onchange = async function() {
|
|
|
|
if (lastConf === this.value) {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
lastConf = this.value
|
|
|
|
|
|
|
|
const err = verifyConf(lastConf)
|
|
|
|
showConfWarn(err)
|
|
|
|
if (err) {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
await pack()
|
|
|
|
}
|
|
|
|
|
|
|
|
function bundle() {
|
|
|
|
const confStr = JSON.stringify(confMap) + '\r'
|
|
|
|
const confBin = strToBytes(confStr)
|
|
|
|
const bufs = [confBin]
|
|
|
|
|
|
|
|
for (const path of Object.keys(confMap)) {
|
|
|
|
const data = dataMap[path]
|
|
|
|
bufs.push(data)
|
|
|
|
}
|
|
|
|
return concatBufs(bufs)
|
|
|
|
}
|
|
|
|
|
|
|
|
//
|
|
|
|
// utils
|
|
|
|
//
|
|
|
|
function strToBytes(str) {
|
|
|
|
return new TextEncoder().encode(str)
|
|
|
|
}
|
|
|
|
|
|
|
|
function bytesToB64(bytes) {
|
|
|
|
return btoa(String.fromCharCode.apply(null, bytes))
|
|
|
|
}
|
|
|
|
|
|
|
|
function parseJson(str) {
|
|
|
|
try {
|
|
|
|
return JSON.parse(str)
|
|
|
|
} catch (err) {
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function parseUrl(url) {
|
|
|
|
try {
|
|
|
|
return new URL(url)
|
|
|
|
} catch (err) {
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function parseRegExp(str) {
|
|
|
|
try {
|
|
|
|
return RegExp(str)
|
|
|
|
} catch (err) {
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function concatBufs(bufs) {
|
|
|
|
let size = 0
|
|
|
|
for (const buf of bufs) {
|
|
|
|
size += buf.length
|
|
|
|
}
|
|
|
|
const ret = new Uint8Array(size)
|
|
|
|
let pos = 0
|
|
|
|
for (const v of bufs) {
|
|
|
|
ret.set(v, pos)
|
|
|
|
pos += v.length
|
|
|
|
}
|
|
|
|
return ret
|
|
|
|
}
|
|
|
|
|
|
|
|
async function sha256(bytes) {
|
|
|
|
const buf = await crypto.subtle.digest('SHA-256', bytes)
|
|
|
|
return new Uint8Array(buf)
|
|
|
|
}
|
|
|
|
|
|
|
|
function isArrayEqual(b1, b2) {
|
|
|
|
if (b1.length !== b2.length) {
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
for (let i = 0; i < b1.length; i++) {
|
|
|
|
if (b1[i] !== b2[i]) {
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
|
|
|
|
function showConfUI() {
|
|
|
|
const confStr = JSON.stringify(confMap, null, '\t')
|
|
|
|
if (confStr === '{}') {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
txtConf.value = confStr
|
|
|
|
txtConf.classList.remove('fold')
|
|
|
|
txtConf.readOnly = false
|
|
|
|
txtUrls.value = ''
|
|
|
|
txtJs.value = ''
|
|
|
|
txtJs.classList.add('fold')
|
|
|
|
optPrivacy.disabled = true
|
|
|
|
chkMinify.disabled = true
|
|
|
|
pack()
|
|
|
|
}
|
|
|
|
|
|
|
|
function showConfWarn(msg) {
|
|
|
|
txtConfWarn.textContent = msg
|
|
|
|
}
|
|
|
|
|
|
|
|
function showUrlsWarn(msg) {
|
|
|
|
txtUrlsWarn.textContent = msg
|
|
|
|
}
|
|
|
|
|
|
|
|
function showCodeWarn(msg) {
|
|
|
|
txtCodeWarn.textContent = msg
|
|
|
|
}
|
|
|
|
|
|
|
|
function verifyImage(url) {
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
const img = new Image()
|
|
|
|
|
|
|
|
img.onload = async () => {
|
|
|
|
if (!hashExp) {
|
|
|
|
resolve()
|
|
|
|
return
|
|
|
|
}
|
|
|
|
const canvas = document.createElement('canvas')
|
|
|
|
canvas.width = img.width
|
|
|
|
canvas.height = img.height
|
|
|
|
|
|
|
|
const ctx = canvas.getContext('2d')
|
|
|
|
ctx.drawImage(img, 0, 0)
|
|
|
|
|
|
|
|
const imgData = ctx.getImageData(0, 0, img.width, img.height)
|
|
|
|
const hashGot = await sha256(imgData.data)
|
|
|
|
|
|
|
|
if (isArrayEqual(hashGot, hashExp)) {
|
|
|
|
resolve()
|
|
|
|
} else {
|
|
|
|
reject('hash incorrect')
|
|
|
|
}
|
|
|
|
}
|
|
|
|
img.onerror = () => {
|
|
|
|
reject('failed to load')
|
|
|
|
}
|
|
|
|
img.crossOrigin = true
|
|
|
|
img.referrerPolicy = 'no-referrer'
|
|
|
|
img.src = url
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
async function verifyUrls(urls) {
|
|
|
|
for (const url of urls) {
|
|
|
|
const urlObj = parseUrl(url)
|
|
|
|
if (!urlObj || !/^https?:/.test(urlObj.protocol)) {
|
|
|
|
return `invalid url: ${url}`
|
|
|
|
}
|
|
|
|
try {
|
|
|
|
await verifyImage(url)
|
|
|
|
} catch(err) {
|
|
|
|
return `${err}: ${url}`
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return ''
|
|
|
|
}
|
|
|
|
|
|
|
|
let tmplCode
|
|
|
|
let imgUrls
|
|
|
|
|
|
|
|
async function genCode() {
|
|
|
|
try {
|
|
|
|
const res = await fetch('assets/js/tmpl.js')
|
|
|
|
tmplCode = await res.text()
|
|
|
|
} catch (err) {
|
|
|
|
showCodeWarn('failed to load code')
|
|
|
|
return
|
|
|
|
}
|
|
|
|
const privacy = optPrivacy.options[optPrivacy.selectedIndex].value
|
|
|
|
const hashStr = hashExp ? bytesToB64(hashExp) : ''
|
|
|
|
const urlsStr = imgUrls.join("', '")
|
|
|
|
let js = `\
|
|
|
|
var HASH = '${hashStr}'
|
|
|
|
var URLS = ['${urlsStr}']
|
|
|
|
var PRIVACY = ${privacy}
|
2021-09-09 15:56:47 +00:00
|
|
|
var UPDATE_TIMER = 120
|
2021-09-05 09:58:19 +00:00
|
|
|
|
|
|
|
${tmplCode}
|
|
|
|
`
|
|
|
|
if (chkMinify.checked) {
|
|
|
|
try {
|
|
|
|
const ret = await Terser.minify(js, {
|
|
|
|
enclose: true,
|
|
|
|
compress: {
|
|
|
|
global_defs: {
|
|
|
|
RELEASE: 1,
|
|
|
|
},
|
|
|
|
}
|
|
|
|
})
|
|
|
|
js = ret.code
|
|
|
|
} catch (err) {
|
|
|
|
showCodeWarn(err.message)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
}
|
|
|
|
txtJs.value = js
|
|
|
|
}
|
|
|
|
|
|
|
|
txtUrls.onchange = async function() {
|
|
|
|
const urls = this.value.split(/\s+/).filter(Boolean)
|
|
|
|
const err = await verifyUrls(urls)
|
|
|
|
|
|
|
|
showUrlsWarn(err)
|
|
|
|
if (err) {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
if (urls.length === 0) {
|
|
|
|
txtJs.value = ''
|
|
|
|
return
|
|
|
|
}
|
|
|
|
imgUrls = urls
|
|
|
|
|
|
|
|
txtJs.classList.remove('fold')
|
|
|
|
optPrivacy.disabled = false
|
|
|
|
chkMinify.disabled = false
|
|
|
|
await genCode()
|
|
|
|
}
|
|
|
|
|
|
|
|
optPrivacy.onchange = function() {
|
|
|
|
genCode()
|
|
|
|
}
|
|
|
|
|
|
|
|
chkMinify.onchange = function() {
|
|
|
|
genCode()
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const MAX_BUNDLE_SIZE = 1024 * 1024 * 20
|
|
|
|
let dataMap = {}
|
|
|
|
let confMap = {}
|
|
|
|
let totalSize = 0
|
|
|
|
|
|
|
|
async function addFile(file, path) {
|
|
|
|
if (checkIgnore(path)) {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
if (totalSize + file.size > MAX_BUNDLE_SIZE) {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
totalSize += file.size
|
|
|
|
|
|
|
|
const data = await readFileData(file)
|
|
|
|
dataMap[path] = data
|
|
|
|
confMap[path] = {
|
|
|
|
'content-type': file.type || 'application/octet-stream',
|
|
|
|
'content-length': file.size,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
let isReading = false
|
|
|
|
let ignoreReg
|
|
|
|
|
|
|
|
function checkIgnore(path) {
|
|
|
|
for (const s of path.split('/')) {
|
|
|
|
if (ignoreReg.test(s)) {
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
|
|
|
|
txtIgnore.onchange = function() {
|
|
|
|
ignoreReg = parseRegExp(this.value || '^$')
|
|
|
|
if (!ignoreReg) {
|
|
|
|
this.classList.add('bad')
|
|
|
|
return
|
|
|
|
}
|
|
|
|
this.classList.remove('bad')
|
|
|
|
}
|
|
|
|
txtIgnore.onchange()
|
|
|
|
|
|
|
|
|
|
|
|
function startReading() {
|
|
|
|
isReading = true
|
|
|
|
totalSize = 0
|
|
|
|
dataMap = {}
|
|
|
|
confMap = {}
|
|
|
|
}
|
|
|
|
|
|
|
|
async function fileDialogHandler() {
|
|
|
|
if (isReading) {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
if (this.files.length === 0) {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
startReading()
|
|
|
|
|
|
|
|
for (const file of this.files) {
|
|
|
|
const path = stripRootDir(file.webkitRelativePath)
|
|
|
|
await addFile(file, path)
|
|
|
|
}
|
|
|
|
showConfUI()
|
|
|
|
isReading = false
|
|
|
|
}
|
|
|
|
|
|
|
|
divDropZone.onclick = function() {
|
|
|
|
const fileDialog = document.createElement('input')
|
|
|
|
fileDialog.type = 'file'
|
|
|
|
fileDialog.webkitdirectory = true
|
|
|
|
fileDialog.onchange = fileDialogHandler
|
|
|
|
fileDialog.click()
|
|
|
|
}
|
|
|
|
|
|
|
|
divDropZone.ondragover = function(e) {
|
|
|
|
e.stopPropagation()
|
|
|
|
e.preventDefault()
|
|
|
|
e.dataTransfer.dropEffect = 'copy'
|
|
|
|
}
|
|
|
|
|
|
|
|
divDropZone.ondrop = async function(e) {
|
|
|
|
e.stopPropagation()
|
|
|
|
e.preventDefault()
|
|
|
|
|
|
|
|
if (isReading) {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
const {items} = e.dataTransfer
|
|
|
|
if (items.length !== 1) {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
const entry = items[0].webkitGetAsEntry()
|
|
|
|
if (!entry.isDirectory) {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
startReading()
|
|
|
|
|
|
|
|
await traverseDir(entry)
|
|
|
|
showConfUI()
|
|
|
|
isReading = false
|
|
|
|
}
|
|
|
|
|
|
|
|
async function traverseDir(entry) {
|
|
|
|
const entires = await getDirEntries(entry)
|
|
|
|
|
|
|
|
for (const entry of entires) {
|
|
|
|
if (entry.isDirectory) {
|
|
|
|
await traverseDir(entry)
|
|
|
|
} else {
|
|
|
|
const file = await entryToFile(entry)
|
|
|
|
const path = stripRootDir(entry.fullPath)
|
|
|
|
await addFile(file, path)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function stripRootDir(path) {
|
|
|
|
return path.replace(/^\/?[^/]+\//, '')
|
|
|
|
}
|
|
|
|
|
|
|
|
function getDirEntries(entry) {
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
entry.createReader().readEntries(entries => {
|
|
|
|
resolve(entries)
|
|
|
|
}, err => {
|
|
|
|
reject(err)
|
|
|
|
})
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
function entryToFile(entry) {
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
entry.file(ret => {
|
|
|
|
resolve(ret)
|
|
|
|
}, err => {
|
|
|
|
reject(err)
|
|
|
|
})
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
function readFileData(file) {
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
const reader = new FileReader()
|
|
|
|
|
|
|
|
reader.onload = () => {
|
|
|
|
const u8 = new Uint8Array(reader.result)
|
|
|
|
resolve(u8)
|
|
|
|
}
|
|
|
|
reader.onerror = () => {
|
|
|
|
reject()
|
|
|
|
}
|
|
|
|
reader.readAsArrayBuffer(file)
|
|
|
|
})
|
|
|
|
}
|