Web 儲存資料的特殊方案
原文:
- 2018/09/06ofollow,noindex" target="_blank">Hijacking HTML canvas and PNG images to store arbitrary text data
- 2018/09/20Retrieving data from hijacked PNG images using HTML canvas and Javascript
需求
實現資料的儲存與載入。
前提是沒有以下支援:
- Cookie
- LocalStorage
- Server Side Storage
設計
- 使用圖片
- 支援幾十 KB 資料
- 不需要考慮特定圖片格式的處理細節
實現
關於儲存為圖片的方案選擇
排除方案 1
資料 -> JSON -> 動態生成HTTP/Basics_of_HTTP/Data_URIs" rel="nofollow,noindex" target="_blank">Data URLs 與下載連結 。
移動端 Safari 上無效:不認<a>
標記的download
屬性。
排除方案 2
二維碼
編碼容易,解碼麻煩,需要一個很大的庫。
選擇方案:Canvas
每三個 ASCII 組成一個 RGB 畫素,不足部分用 0 填充。
問題:圖片太小的話,不便於點選儲存(?原話:this is not easy to tap to save)
方案:預設大小:256 * 256
第一行保留(最後一列順帶著保留了,因為是資料方陣的設計)用來記錄元資料,比如資料規模。
可用空間:255 * 255,65025 => 190KB
JSON 字串 ---TextEncoder---> 位元組陣列
> (new TextEncoder('utf-8')).encode('Hello World') Uint8Array(11) [72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100]
測試資料:
$ curl -X POST -qd "name=胡昂&location=中華人民共和國&job=軟體開發工程師" http://httpbin.org/post | jq --tab
主要邏輯:
function createElementFromHTML(html) { var emptyElement = document.createElement('empty'); emptyElement.innerHTML = html.trim(); return emptyElement.firstChild; } var exportedImage = null; var importedData = null; function exportData(data) { // JSON 序列化 var strData = JSON.stringify(data); // TextEncoder 編碼成數字 var uint8array = (new TextEncoder('utf-8')).encode(strData); // 計算需要多大方陣儲存全部資料 var dataSize = Math.ceil(Math.sqrt(uint8array.length / 3)); // 用 255 填充 RGBA 中的 alpha 通道,設定完全不透明 // 否則 alpha = 0,即透明圖片)會遇到 RGB 損壞的問題 // https://stackoverflow.com/questions/22384423/canvas-corrupts-rgb-when-alpha-0 var paddedData = new Uint8ClampedArray(dataSize * dataSize * 4); var idx = 0; // 每三個數字後加一個 255 for (var i = 0; i < uint8array.length; i += 3) { var subArray = uint8array.subarray(i, i + 3); paddedData.set(subArray, idx); paddedData.set([255], idx + 3); idx += 4; } // 生成影象資料 var imageData = new ImageData(paddedData, dataSize, dataSize); // 建立一個屏外(off screen)畫布 var imgSize = 256; var canvas = document.createElement('canvas'); canvas.width = canvas.height = imgSize; // 獲取畫布上下文 var ctx = canvas.getContext('2d'); // 畫布設定:256 * 256 的黑布 ctx.fillStyle = '#AA0000'; // 顏色沒關係 ctx.fillRect(0, 0, imgSize, imgSize); // 用一個畫素的 R 值記錄資料規模 ctx.fillStyle = 'rgb(' + dataSize + ', 0, 0)'; ctx.fillRect(0, 0, 1, 1); // 畫布填充內容 ctx.putImageData(imageData, 0, 1); var body = document.getElementsByTagName('body')[0]; exportedImage = canvas.toDataURL(); var downloadedLink = createElementFromHTML('<a id="hiddenLink" href="' + exportedImage + '" style="display:;" download="image.png">Download</a>'); body.appendChild(downloadedLink); // MDN 中 Element 沒有這個方法,可能不通用 // downloadedLink.click(); var e = document.createEvent("MouseEvents"); e.initEvent("click", true, true); // downloadedLink.dispatchEvent(e); // 無效 var downloadedLink = document.getElementById('hiddenLink'); downloadedLink.dispatchEvent(e); body.removeChild(downloadedLink); } // exportData 的反向操作 function importData(imgSrc, callback) { var img = new Image(); img.onload = function () { // 先把圖片填充到畫布上 var imgSize = img.width; var canvas = document.createElement('canvas'); canvas.width = canvas.height = imgSize; var ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0); // 獲取資料規模 var headerData = ctx.getImageData(0, 0, 1, 1); var dataSize = headerData.data[0]; // 獲取資料 var imageData = ctx.getImageData(0, 1, dataSize, dataSize); var paddedData = imageData.data; // 拋棄每一個畫素資料中的第四位(RGBA 中的 alpha 通道) var uint8array = new Uint8Array(paddedData.length / 4 * 3); var idx = 0; for (var i = 0; i < paddedData.length - 1; i += 4) { var subArray = paddedData.subarray(i, i + 3); uint8array.set(subArray, idx); idx += 3; } // 將最後為 0 的填充資料去掉 var includeBytes = uint8array.length; for (var i = uint8array.length - 1; i > 0; i--) { if (uint8array[i] === 0) { includeBytes--; } else { break; } } var data = uint8array.subarray(0, includeBytes); var strData = (new TextDecoder('utf-8')).decode(data); try { importedData = JSON.parse(strData); if (callback) { callback(); } } catch (error) { if (callback) { callback(error); } } }; // 可以設定 src 屬性為普通 URL 或 Data URL img.src = imgSrc; }