UNPKG

butter-lib

Version:

BuTTER Library は、ストレージ上に細分化した状態で保存されているGTFSを基にした時刻表情報を集め、ブラウザ内で必要な情報に加工するライブラリです。DBを使わずにデータ処理をブラウザ内とする

552 lines (506 loc) 18 kB
const crypto = require('crypto') const axios = require('axios') const pako = require('pako') const inflate = pako.inflate global.CONFIG = { debug: true, cacheSize: 1024, useFetch: false } const RUNTIME = { cache: {}, consumedOp: 0 } /** * 指定されたURLからリソースをフェッチし、ArrayBufferとして返します。 * @param {string} url - リソースをフェッチするURL。 * @returns {Promise<ArrayBuffer>} フェッチされたリソースをArrayBufferとして解決するプロミス。 * @throws {Error} フェッチリクエストが失敗した場合にエラーを投げます。 */ export const fetchAsArrayBuffer = async (url) => { try { let response if (global.CONFIG.useFetch) { response = await fetch(url) const buffer = await response.arrayBuffer() return buffer } else { response = await axios.get(url, { responseType: 'arraybuffer' }) return response.data } } catch (error) { console.error(`Failed to fetch ${url}:`, error) throw new Error(`Failed to fetch ${url}`) } } /** * PEM形式の文字列を公開鍵に変換します。 * @param {string} pem - PEM形式の文字列。 * @returns {Promise<CryptoKey>} インポートされた公開鍵を解決するプロミス。 */ const pemToPublicKey = async (pem) => { const uint8Array = pemToUint8Array(pem) return await crypto.subtle.importKey( 'spki', uint8Array, { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['verify'] ) } /** * PEM形式の文字列をUint8Arrayに変換します。 * @param {string} pem - PEM形式の文字列。 * @returns {Uint8Array} PEM文字列のUint8Array表現。 */ export const pemToUint8Array = (pem) => { const base64String = pem.replace(/-----[A-Z ]+-----/g, '').trim() const raw = window.atob(base64String) const uint8Array = new Uint8Array(raw.length) for (let i = 0; i < raw.length; i++) { uint8Array[i] = raw.charCodeAt(i) } return uint8Array } let cachedPublicKey = null /** * キャッシュされた公開鍵を取得するか、キャッシュされていない場合は指定されたURLからフェッチします。 * @param {string} url - 公開鍵をフェッチするURL。 * @returns {Promise<CryptoKey>} 公開鍵を解決するプロミス。 */ const getPublicKeyFromCache = async (url) => { if (!cachedPublicKey) { let response console.log({ url }) if (global.CONFIG.useFetch) { response = await fetch(url) const text = await response.text() cachedPublicKey = await pemToPublicKey(text) } else { response = await axios.get(url, { responseType: 'text' }) cachedPublicKey = await pemToPublicKey(response.data) } } return cachedPublicKey } /** * 指定されたURLからのコンテンツの署名を公開鍵を使用して検証します。 * @param {string} publicKeyUrl - 公開鍵をフェッチするURL。 * @param {string} contentUrl - コンテンツをフェッチするURL。 * @param {string} signatureUrl - 署名をフェッチするURL。 * @returns {Promise<boolean>} 署名の検証結果を解決するプロミス。 */ const verifySignatureFromUrls = async (publicKeyUrl, contentUrl, signatureUrl) => { // SSLでない場合など、crypto.subtleが存在しない場合は常にtrueを返す const isNode = typeof process !== 'undefined' && process.versions != null && process.versions.node != null; if (isNode) return true if (!crypto?.subtle) return true const [publicKey, content, signature] = await Promise.all([ getPublicKeyFromCache(publicKeyUrl), fetchAsArrayBuffer(contentUrl), fetchAsArrayBuffer(signatureUrl) ]) return await crypto.subtle.verify( { name: 'RSASSA-PKCS1-v1_5' }, publicKey, signature, content ) } /** * Helperオブジェクトは、キャッシュ操作、暗号化、日付処理などのユーティリティ関数を提供します。 */ export const helper = { setUseFetch (useFetch) { global.CONFIG.useFetch = useFetch }, fetchAsArrayBuffer, /** * キャッシュにオブジェクトを保存します。キャッシュサイズが最大に達している場合、ランダムなアイテムを削除します。 * @param {string} key - キャッシュするオブジェクトのキー。 * @param {any} obj - キャッシュするオブジェクト。 * @param {Object} kvStore - キャッシュを格納するオブジェクト。デフォルトはRUNTIME.cache。 * @param {number} cacheMaxSize - キャッシュの最大サイズ。デフォルトはCONFIG.cacheSize。 */ storeCache (key, obj, kvStore = RUNTIME.cache, cacheMaxSize = global.CONFIG.cacheSize) { const keys = Object.keys(kvStore) if (keys.length >= cacheMaxSize) { const randIndex = Math.floor(Math.random() * keys.length) delete kvStore[keys[randIndex]] } kvStore[key] = obj }, /** * 指定されたキーに対応するキャッシュを取得します。 * @param {string} key - 取得するキャッシュのキー。 * @param {Object} kvStore - キャッシュが格納されているオブジェクト。デフォルトはRUNTIME.cache。 * @returns {any} キャッシュされたオブジェクト、またはキャッシュがない場合はundefined。 */ loadCache (key, kvStore = RUNTIME.cache) { return kvStore[key] }, fetchPublicKey: async (url) => { try { let response if (global.CONFIG.useFetch) { response = await fetch(url) if (!response.ok) throw new Error('Failed to fetch the public key') const arrayBuffer = await response.arrayBuffer() return arrayBuffer } else { response = await axios.get(url, { responseType: 'arraybuffer' }) if (response.status !== 200) throw new Error('Failed to fetch the public key') return response.data } } catch (error) { console.error('Error fetching public key: ', error) throw error } }, verifyFileSignature: async (url, publicKey) => { try { // ファイルの取得 let fileResponse let sigResponse let fileContent, sigContent if (global.CONFIG.useFetch) { fileResponse = await fetch(url) sigResponse = await fetch(`${url}.sig`) if (!fileResponse.ok || !sigResponse.ok) { throw new Error('Failed to fetch the file or signature') } fileContent = await fileResponse.arrayBuffer() sigContent = await sigResponse.arrayBuffer() } else { fileResponse = await axios.get(url, { responseType: 'arraybuffer' }) sigResponse = await axios.get(`${url}.sig`, { responseType: 'arraybuffer' }) if (fileResponse.status !== 200 || sigResponse.status !== 200) { throw new Error('Failed to fetch the file or signature') } fileContent = fileResponse.data sigContent = sigResponse.data } console.log('File Content:', fileContent) console.log('Signature Content:', sigContent) const fileHash = await helper.hash.SHA256(new Uint8Array(fileContent)) console.log('File Hash:', fileHash) return await helper.verifySignature(publicKey, fileHash, new Uint8Array(sigContent)) } catch (error) { console.error('Verification failed', error) return false } }, verifySignature: async function (publicKey, messageHash, signature) { try { // TODO ここが正しく実行されているか要検証 publicKey = pemToUint8Array(publicKey) console.log(publicKey, messageHash, signature) console.log('Public Key at verifySignature:', publicKey) const importedKey = await window.crypto.subtle.importKey( 'spki', publicKey, { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['verify'] ) console.log('Imported Key:', importedKey) return (await crypto.subtle.verify( 'RSASSA-PKCS1-v1_5', importedKey // uint8ToArrayBuffer(signature), // uint8ToArrayBuffer(messageHash) )) } catch (error) { console.error('Error in verifySignature:', error) throw error // re-throw the error after logging it } }, async fetchJSON (url, publicKeyUrl) { const cache = helper.loadCache(url) if (cache) { return cache } try { if (global.CONFIG.debug) { console.log(url) } RUNTIME.consumedOp++ let response if (global.CONFIG.useFetch) { response = await fetch(url) if (!response.ok) throw new Error('Failed to fetch JSON') } else { response = await axios.get(url) } if(publicKeyUrl){ // 署名の検証 const contentUrl = url const signatureUrl = url + '.sig' const isValid = await verifySignatureFromUrls(publicKeyUrl, contentUrl, signatureUrl) if (!isValid) { throw new Error('Invalid signature') } } // JSONを解析 const data = global.CONFIG.useFetch ? await response.json() : response.data helper.storeCache(url, data) return data } catch (e) { console.log({ error: e }) throw new Error('something wrong2'+e.message) } }, /** * 指定されたURLからJSONを取得し、パースします。 * @param {string} url - データを取得するURL。 * @returns {Promise<Object>} - パースされたJSONオブジェクト。 */ async fetchAndParseJSON (url) { try { let data if (global.CONFIG.useFetch) { const response = await fetch(url) if (!response.ok) { throw new Error(`Failed to fetch JSON: Status ${response.status}`) } data = await response.json() } else { const response = await axios.get(url) if (response.status !== 200) { throw new Error(`Failed to fetch JSON: Status ${response.status}`) } data = response.data } console.log('Response Data:', data) return data } catch (error) { console.error('Error during fetch:', error) // エラーの再スロー throw error } }, async fetchTarCSV (url) { const cache = helper.loadCache(url) if (cache) { return cache } // tarファイルを解凍する関数 // 機能的には関数に独立させたいが,この関数の内部でしか使わないのでここでいいや const untar = (data) => { const files = [] const headerSize = 512 let offset = 0 while (offset < data.length) { const header = data.slice(offset, offset + headerSize) // データ変換のための環境判定 let nameEncoded, sizeEncoded if (typeof Buffer !== 'undefined') { // Node.js 環境 nameEncoded = Buffer.from(header.subarray(0, 100)).toString('utf8').replace(/\0/g, '').trim() sizeEncoded = Buffer.from(header.subarray(124, 136)).toString('utf8').trim() } else { // ブラウザ環境 nameEncoded = new TextDecoder('utf-8').decode(header.subarray(0, 100)).replace(/\0/g, '').trim() sizeEncoded = new TextDecoder('utf-8').decode(header.subarray(124, 136)).trim() } const name = decodeURI(nameEncoded) const size = parseInt(sizeEncoded, 8) offset += headerSize if (name) { const buffer = data.slice(offset, offset + size) files.push({ name, size, buffer }) } offset += size if (size % 512 !== 0) { offset += 512 - (size % 512) } } return files } if (global.CONFIG.debug) { console.log(url) } try { RUNTIME.consumedOp++ let response, gzipData if (global.CONFIG.useFetch) { response = await fetch(url) if (!response.ok) throw new Error('Failed to fetch Tar CSV') const arrayBuffer = await response.arrayBuffer() gzipData = new Uint8Array(arrayBuffer) } else { response = await axios.get(url, { responseType: 'arraybuffer' }) if (response.status !== 200) throw new Error('Failed to fetch Tar CSV') gzipData = new Uint8Array(response.data) } // gzip形式のデータを解凍する const rawBytes = inflate(gzipData) // tarファイルを解凍する const files = untar(rawBytes) const ret = {} // ファイルの中身を操作する files.forEach((file) => { const name = file.name // データ変換のための環境判定 let content if (typeof Buffer !== 'undefined') { // Node.js 環境 content = Buffer.from(file.buffer).toString('utf8') } else { // ブラウザ環境 content = new TextDecoder('utf-8').decode(file.buffer) } if (name.endsWith('.sig')) { // TODO: 署名ファイルの処理 } else { const obj = helper.csvToObject(content) ret[name] = obj } }) helper.storeCache(url, ret) return ret } catch (error) { console.error(error) throw new Error('something wrong') // TODO: エラーメッセージを具体化 } }, fetchCSV: async (url) => { const cache = helper.loadCache(url) if (cache) { return cache } try { if (global.CONFIG.debug) { console.log(url) } RUNTIME.consumedOp++ let response if (global.CONFIG.useFetch) { response = await fetch(url) if (!response.ok) throw new Error('something wrong') const text = await response.text() const data = helper.csvToObject(text) helper.storeCache(url, data) return data } else { response = await axios.get(url, { responseType: 'text' }) if (response.status !== 200) throw new Error('something wrong') const data = helper.csvToObject(response.data) helper.storeCache(url, data) return data } } catch (error) { console.error(error) throw new Error('something wrong') } }, /** * CSV形式の文字列をオブジェクトの配列に変換します。 * @param {string} csv - 変換するCSV形式の文字列。 * @returns {Object[]} ヘッダーをキーとするオブジェクトの配列。 */ csvToObject (csv) { const lines = csv.replace(/\r/g, '').trim().split('\n') const headers = lines.shift().split(',') return lines.map(line => { const values = line.split(',') return headers.reduce((obj, header, index) => { obj[header] = values[index] return obj }, {}) }) }, async sleep (ms) { return new Promise(resolve => setTimeout(resolve, ms)) }, parseDate (dateStr) { // YYYYMMDDの形式 const year = dateStr.slice(0, 4) const month = dateStr.slice(4, 6) const day = dateStr.slice(6, 8) return new Date(`${year}-${month}-${day}`) }, getDayOfWeek (dateStr) { const date = helper.parseDate(dateStr) // 曜日を取得する const weekdays = ['日', '月', '火', '水', '木', '金', '土'] return weekdays[date.getDay()] }, // isHoliday (dateStr) { // const dayOfWeek = helper.getDayOfWeek(dateStr) // flags = [ // ['土', '日'].includes(dayOfWeek) // ] // return flags.some(f => f) // }, /** * 二つのセットの共通要素を含む新しいセットを返します。 * @param {Set<any>} setA - 最初のセット。 * @param {Set<any>} setB - 二番目のセット。 * @returns {Set<any>} 両セットの共通要素を含む新しいセット。 */ setIntersection (setA, setB) { const intersection = new Set() setB.forEach(e => { if (setA.has(e)) intersection.add(e) }) return intersection }, /** * 現在のRUNTIMEで消費された操作の数を返します。 * @returns {number} 消費された操作の数。 */ getConsumedOp () { return RUNTIME.consumedOp }, hash: { /** * 与えられたメッセージのSHA-256ハッシュを計算します。 * @param {string} msg - ハッシュを計算するメッセージ。 * @returns {Promise<string>} メッセージのSHA-256ハッシュ。 */ async SHA256 (msg) { return crypto.createHash('sha256').update(msg, 'utf8').digest('hex') }, async v1 (str, mod) { const hash = helper.hash.SHA256(str) if (global.CONFIG.debug) { console.log(str, hash) } return (await hash).slice(0, mod) }, async v2 (str, mod) { const hash = helper.hash.SHA256(encodeURI(str)) if (global.CONFIG.debug) { console.log(str, hash) } return (await hash).slice(0, mod) } }, toSnakeCase(str) { return str.replace(/([A-Z])/g, function($1){return "_"+$1.toLowerCase();}); }, convertKeysToSnakeCase(obj) { if (typeof obj !== 'object' || obj === null) { return obj; } if (Array.isArray(obj)) { return obj.map(convertKeysToSnakeCase); } return Object.keys(obj).reduce(function(acc, key) { const newKey = helper.toSnakeCase(key); acc[newKey] = helper.convertKeysToSnakeCase(obj[key]); return acc; }, {}); }, }