diff --git a/src/plugins/nodePolyfills.ts b/src/plugins/nodePolyfills.ts index 9c9a6b2..6b24454 100644 --- a/src/plugins/nodePolyfills.ts +++ b/src/plugins/nodePolyfills.ts @@ -274,36 +274,354 @@ const utilModule: any = { types: { isDate: (x: any) => x instanceof Date, isRegExp: (x: any) => x instanceof RegExp, isError: (x: any) => x instanceof Error, isArray: Array.isArray, isFunction: (x: any) => typeof x === 'function' }, }; +// Crypto helpers using Web Crypto API +async function subtleDigest(algo: string, data: Uint8Array): Promise { + const alg = algo === 'md5' ? 'MD5' : algo.toUpperCase(); + const name = alg === 'MD5' ? 'MD5' : alg === 'SHA1' ? 'SHA-1' : alg === 'SHA256' ? 'SHA-256' : alg === 'SHA512' ? 'SHA-512' : alg; + const sub = (globalThis as any).crypto?.subtle; + if (sub) { + try { return new Uint8Array(await sub.digest(name, data)); } catch { /* fallthrough */ } + } + return simpleHash(algo, data); +} + +function simpleHash(algo: string, data: Uint8Array): Uint8Array { + let s = ''; + for (let i = 0; i < data.length; i++) s += String.fromCharCode(data[i]); + const algoKey = algo.toLowerCase(); + let h: number; + if (algoKey === 'md5') { + let a = 0x67452301, b = 0xefcdab89, c = 0x98badcfe, d = 0x10325476; + const msg = new Uint8Array(data.length + 1); + msg.set(data); + const bitLen = data.length * 8; + msg[data.length] = 0x80; + const paddedLen = (((data.length + 9 + 63) & ~63) + 64) * 4; + const padded = new Uint8Array(paddedLen); + padded.set(msg); + const view = new DataView(padded.buffer); + view.setUint32(paddedLen - 8, bitLen >>> 32, true); + view.setUint32(paddedLen - 4, bitLen & 0xffffffff, true); + for (let i = 0; i < paddedLen; i += 64) { + const w = new Uint32Array(80); + for (let j = 0; j < 16; j++) w[j] = view.getUint32(i + j * 4, true); + for (let j = 16; j < 80; j++) { + w[j] = (w[j-3] ^ w[j-8] ^ w[j-14] ^ w[j-16]); + w[j] = (w[j] << 1) | (w[j] >>> 31); + } + let A = a, B = b, C = c, D = d; + for (let j = 0; j < 80; j++) { + let F: number, g: number; + if (j < 20) { F = (B & C) | (~B & D); g = 0x5a827999; } + else if (j < 40) { F = B ^ C ^ D; g = 0x6ed9eba1; } + else if (j < 60) { F = (B & C) | (B & D) | (C & D); g = 0x8f1bbcdc; } + else { F = B ^ C ^ D; g = 0xca62c1d6; } + const temp = ((A << 5) | (A >>> 27)) + F + w[j] + g; + D = C; C = B; B = (B << 30) | (B >>> 2); A = temp; + } + a += A; b += B; c += C; d += D; + } + const out = new Uint8Array(16); + const v = new DataView(out.buffer); + v.setUint32(0, a, true); v.setUint32(4, b, true); + v.setUint32(8, c, true); v.setUint32(12, d, true); + return out; + } + let hash = 0; + for (let i = 0; i < s.length; i++) hash = ((hash << 5) - hash + s.charCodeAt(i)) | 0; + const hex = Math.abs(hash).toString(16).padStart(8, '0'); + const out = new Uint8Array(8); + for (let i = 0; i < 8; i++) out[i] = parseInt(hex.substr(i*2, 2), 16); + return out; +} + +function toBuffer(data: any): Uint8Array { + if (data instanceof Uint8Array) return data; + if (typeof data === 'string') return new TextEncoder().encode(data); + if (Array.isArray(data)) return new Uint8Array(data); + if (data?.buffer instanceof ArrayBuffer) return new Uint8Array(data.buffer); + return new Uint8Array(0); +} + +function bufferToString(buf: Uint8Array, encoding?: string): string { + if (encoding === 'hex') { + let s = ''; + for (let i = 0; i < buf.length; i++) s += buf[i].toString(16).padStart(2, '0'); + return s; + } + if (encoding === 'base64') { + let s = ''; + for (let i = 0; i < buf.length; i++) s += String.fromCharCode(buf[i]); + try { return btoa(s); } catch { return s; } + } + return new TextDecoder().decode(buf); +} + +// Minimal AES-128-ECB implementation (pure JS, for environments where Web Crypto ECB is unavailable) +function aesEcbEncrypt(data: Uint8Array, key: Uint8Array): Uint8Array { + if (key.length !== 16) throw new Error('AES-128 key must be 16 bytes'); + const Nb = 4, Nk = 4, Nr = 10; + const sbox = [0x63,0x7c,0x77,0x7b,0xf2,0x6b,0x6f,0xc5,0x30,0x01,0x67,0x2b,0xfe,0xd7,0xab,0x76,0xca,0x82,0xc9,0x7d,0xfa,0x59,0x47,0xf0,0xad,0xd4,0xa2,0xaf,0x9c,0xa4,0x72,0xc0,0xb7,0xfd,0x93,0x26,0x36,0x3f,0xf7,0xcc,0x34,0xa5,0xe5,0xf1,0x71,0xd8,0x31,0x15,0x04,0xc7,0x23,0xc3,0x18,0x96,0x05,0x9a,0x07,0x12,0x80,0xe2,0xeb,0x27,0xb2,0x75,0x09,0x83,0x2c,0x1a,0x1b,0x6e,0x5a,0xa0,0x52,0x3b,0xd6,0xb3,0x29,0xe3,0x2f,0x84,0x53,0xd1,0x00,0xed,0x20,0xfc,0xb1,0x5b,0x6a,0xcb,0xbe,0x39,0x4a,0x4c,0x58,0xcf,0xd0,0xef,0xaa,0xfb,0x43,0x4d,0x33,0x85,0x45,0xf9,0x02,0x7f,0x50,0x3c,0x9f,0xa8,0x51,0xa3,0x40,0x8f,0x92,0x9d,0x38,0xf5,0xbc,0xb6,0xda,0x21,0x10,0xff,0xf3,0xd2,0xcd,0x0c,0x13,0xec,0x5f,0x97,0x44,0x17,0xc4,0xa7,0x7e,0x3d,0x64,0x5d,0x19,0x73,0x60,0x81,0x4f,0xdc,0x22,0x2a,0x90,0x88,0x46,0xee,0xb8,0x14,0xde,0x5e,0x0b,0xdb,0xe0,0x32,0x3a,0x0a,0x49,0x06,0x24,0x5c,0xc2,0xd3,0xac,0x62,0x91,0x95,0xe4,0x79,0xe7,0xc8,0x37,0x6d,0x8d,0xd5,0x4e,0xa9,0x6c,0x56,0xf4,0xea,0x65,0x7a,0xae,0x08,0xba,0x78,0x25,0x2e,0x1c,0xa6,0xb4,0xc6,0xe8,0xdd,0x74,0x1f,0x4b,0xbd,0x8b,0x8a,0x70,0x3e,0xb5,0x66,0x48,0x03,0xf6,0x0e,0x61,0x35,0x57,0xb9,0x86,0xc1,0x1d,0x9e,0xe1,0xf8,0x98,0x11,0x69,0xd9,0x8e,0x94,0x9b,0x1e,0x87,0xe9,0xce,0x55,0x28,0xdf,0x8c,0xa1,0x89,0x0d,0xbf,0xe6,0x42,0x68,0x41,0x99,0x2d,0x0f,0xb0,0x54,0xbb,0x16]; + const rcon = [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36]; + const w = new Uint8Array(Nb * (Nr + 1) * 4); + for (let i = 0; i < Nk; i++) { w[i*4] = key[i*4]; w[i*4+1] = key[i*4+1]; w[i*4+2] = key[i*4+2]; w[i*4+3] = key[i*4+3]; } + for (let i = Nk; i < Nb * (Nr + 1); i++) { + let temp = [w[(i-1)*4], w[(i-1)*4+1], w[(i-1)*4+2], w[(i-1)*4+3]]; + if (i % Nk === 0) { + const t = temp[0]; temp[0] = sbox[temp[1]] ^ rcon[i/Nk - 1]; temp[1] = sbox[temp[2]]; temp[2] = sbox[temp[3]]; temp[3] = sbox[t]; + } + w[i*4] = w[(i-Nk)*4] ^ temp[0]; w[i*4+1] = w[(i-Nk)*4+1] ^ temp[1]; w[i*4+2] = w[(i-Nk)*4+2] ^ temp[2]; w[i*4+3] = w[(i-Nk)*4+3] ^ temp[3]; + } + function subWord(r: Uint8Array) { for (let i = 0; i < 4; i++) r[i] = sbox[r[i]]; } + function rotWord(r: Uint8Array) { const t = r[0]; r[0] = r[1]; r[1] = r[2]; r[2] = r[3]; r[3] = t; } + function addRoundKey(state: Uint8Array, round: number) { for (let i = 0; i < 16; i++) state[i] ^= w[round * 16 + i]; } + function subBytes(state: Uint8Array) { for (let i = 0; i < 16; i++) state[i] = sbox[state[i]]; } + function shiftRows(state: Uint8Array) { + const t = state[1]; state[1] = state[5]; state[5] = state[9]; state[9] = state[13]; state[13] = t; + const t2 = state[2]; state[2] = state[10]; state[10] = t2; + const t3 = state[6]; state[6] = state[14]; state[14] = t3; + const t4 = state[15]; state[15] = state[11]; state[11] = state[7]; state[7] = state[3]; state[3] = t4; + } + function mixColumns(state: Uint8Array) { + for (let c = 0; c < 4; c++) { + const i = c * 4; + const a = [state[i], state[i+1], state[i+2], state[i+3]]; + state[i] = gmul(2, a[0]) ^ gmul(3, a[1]) ^ a[2] ^ a[3]; + state[i+1] = a[0] ^ gmul(2, a[1]) ^ gmul(3, a[2]) ^ a[3]; + state[i+2] = a[0] ^ a[1] ^ gmul(2, a[2]) ^ gmul(3, a[3]); + state[i+3] = gmul(3, a[0]) ^ a[1] ^ a[2] ^ gmul(2, a[3]); + } + } + function gmul(a: number, b: number): number { + let p = 0; + for (let i = 0; i < 8; i++) { + if (b & 1) p ^= a; + const hi = a & 0x80; + a = (a << 1) & 0xff; + if (hi) a ^= 0x1b; + b >>= 1; + } + return p; + } + const padded = data.length % 16 === 0 ? new Uint8Array(data) : (() => { const p = 16 - (data.length % 16); const out = new Uint8Array(data.length + p); out.set(data); for (let i = 0; i < p; i++) out[data.length + i] = p; return out; })(); + const blocks = padded.length / 16; + const out = new Uint8Array(padded.length); + for (let b = 0; b < blocks; b++) { + const state = new Uint8Array(padded.subarray(b * 16, (b + 1) * 16)); + addRoundKey(state, 0); + for (let round = 1; round <= Nr; round++) { + subBytes(state); + shiftRows(state); + if (round < Nr) mixColumns(state); + addRoundKey(state, round); + } + out.set(state, b * 16); + } + if (data.length % 16 !== 0) return out.subarray(0, data.length); + return out; +} + +function aesEcbDecrypt(data: Uint8Array, key: Uint8Array): Uint8Array { + if (key.length !== 16) throw new Error('AES-128 key must be 16 bytes'); + const Nb = 4, Nk = 4, Nr = 10; + const sbox = [0x63,0x7c,0x77,0x7b,0xf2,0x6b,0x6f,0xc5,0x30,0x01,0x67,0x2b,0xfe,0xd7,0xab,0x76,0xca,0x82,0xc9,0x7d,0xfa,0x59,0x47,0xf0,0xad,0xd4,0xa2,0xaf,0x9c,0xa4,0x72,0xc0,0xb7,0xfd,0x93,0x26,0x36,0x3f,0xf7,0xcc,0x34,0xa5,0xe5,0xf1,0x71,0xd8,0x31,0x15,0x04,0xc7,0x23,0xc3,0x18,0x96,0x05,0x9a,0x07,0x12,0x80,0xe2,0xeb,0x27,0xb2,0x75,0x09,0x83,0x2c,0x1a,0x1b,0x6e,0x5a,0xa0,0x52,0x3b,0xd6,0xb3,0x29,0xe3,0x2f,0x84,0x53,0xd1,0x00,0xed,0x20,0xfc,0xb1,0x5b,0x6a,0xcb,0xbe,0x39,0x4a,0x4c,0x58,0xcf,0xd0,0xef,0xaa,0xfb,0x43,0x4d,0x33,0x85,0x45,0xf9,0x02,0x7f,0x50,0x3c,0x9f,0xa8,0x51,0xa3,0x40,0x8f,0x92,0x9d,0x38,0xf5,0xbc,0xb6,0xda,0x21,0x10,0xff,0xf3,0xd2,0xcd,0x0c,0x13,0xec,0x5f,0x97,0x44,0x17,0xc4,0xa7,0x7e,0x3d,0x64,0x5d,0x19,0x73,0x60,0x81,0x4f,0xdc,0x22,0x2a,0x90,0x88,0x46,0xee,0xb8,0x14,0xde,0x5e,0x0b,0xdb,0xe0,0x32,0x3a,0x0a,0x49,0x06,0x24,0x5c,0xc2,0xd3,0xac,0x62,0x91,0x95,0xe4,0x79,0xe7,0xc8,0x37,0x6d,0x8d,0xd5,0x4e,0xa9,0x6c,0x56,0xf4,0xea,0x65,0x7a,0xae,0x08,0xba,0x78,0x25,0x2e,0x1c,0xa6,0xb4,0xc6,0xe8,0xdd,0x74,0x1f,0x4b,0xbd,0x8b,0x8a,0x70,0x3e,0xb5,0x66,0x48,0x03,0xf6,0x0e,0x61,0x35,0x57,0xb9,0x86,0xc1,0x1d,0x9e,0xe1,0xf8,0x98,0x11,0x69,0xd9,0x8e,0x94,0x9b,0x1e,0x87,0xe9,0xce,0x55,0x28,0xdf,0x8c,0xa1,0x89,0x0d,0xbf,0xe6,0x42,0x68,0x41,0x99,0x2d,0x0f,0xb0,0x54,0xbb,0x16]; + const invSbox = new Uint8Array(256); + for (let i = 0; i < 256; i++) invSbox[sbox[i]] = i; + const rcon = [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36]; + const w = new Uint8Array(Nb * (Nr + 1) * 4); + for (let i = 0; i < Nk; i++) { w[i*4] = key[i*4]; w[i*4+1] = key[i*4+1]; w[i*4+2] = key[i*4+2]; w[i*4+3] = key[i*4+3]; } + for (let i = Nk; i < Nb * (Nr + 1); i++) { + let temp = [w[(i-1)*4], w[(i-1)*4+1], w[(i-1)*4+2], w[(i-1)*4+3]]; + if (i % Nk === 0) { const t = temp[0]; temp[0] = sbox[temp[1]] ^ rcon[i/Nk - 1]; temp[1] = sbox[temp[2]]; temp[2] = sbox[temp[3]]; temp[3] = sbox[t]; } + w[i*4] = w[(i-Nk)*4] ^ temp[0]; w[i*4+1] = w[(i-Nk)*4+1] ^ temp[1]; w[i*4+2] = w[(i-Nk)*4+2] ^ temp[2]; w[i*4+3] = w[(i-Nk)*4+3] ^ temp[3]; + } + function addRoundKey(state: Uint8Array, round: number) { for (let i = 0; i < 16; i++) state[i] ^= w[round * 16 + i]; } + function invSubBytes(state: Uint8Array) { for (let i = 0; i < 16; i++) state[i] = invSbox[state[i]]; } + function invShiftRows(state: Uint8Array) { + const t = state[13]; state[13] = state[9]; state[9] = state[5]; state[5] = state[1]; state[1] = t; + const t2 = state[2]; state[2] = state[10]; state[10] = t2; + const t3 = state[14]; state[14] = state[6]; state[6] = t3; + const t4 = state[3]; state[3] = state[7]; state[7] = state[11]; state[11] = state[15]; state[15] = t4; + } + function invMixColumns(state: Uint8Array) { + for (let c = 0; c < 4; c++) { + const i = c * 4; + const a = [state[i], state[i+1], state[i+2], state[i+3]]; + state[i] = gmul(14, a[0]) ^ gmul(11, a[1]) ^ gmul(13, a[2]) ^ gmul(9, a[3]); + state[i+1] = gmul(9, a[0]) ^ gmul(14, a[1]) ^ gmul(11, a[2]) ^ gmul(13, a[3]); + state[i+2] = gmul(13, a[0]) ^ gmul(9, a[1]) ^ gmul(14, a[2]) ^ gmul(11, a[3]); + state[i+3] = gmul(11, a[0]) ^ gmul(13, a[1]) ^ gmul(9, a[2]) ^ gmul(14, a[3]); + } + } + function gmul(a: number, b: number): number { + let p = 0; + for (let i = 0; i < 8; i++) { + if (b & 1) p ^= a; + const hi = a & 0x80; + a = (a << 1) & 0xff; + if (hi) a ^= 0x1b; + b >>= 1; + } + return p; + } + if (data.length % 16 !== 0) throw new Error('AES data must be multiple of 16 bytes'); + const blocks = data.length / 16; + const out = new Uint8Array(data.length); + for (let b = 0; b < blocks; b++) { + const state = new Uint8Array(data.subarray(b * 16, (b + 1) * 16)); + addRoundKey(state, Nr); + for (let round = Nr - 1; round >= 0; round--) { + invShiftRows(state); + invSubBytes(state); + addRoundKey(state, round); + if (round > 0) invMixColumns(state); + } + out.set(state, b * 16); + } + return out; +} + +async function aesCbcEncrypt(data: Uint8Array, key: Uint8Array, iv: Uint8Array): Promise { + const sub = (globalThis as any).crypto?.subtle; + if (sub) { + const alg = { name: 'AES-CBC', iv }; + const k = await sub.importKey('raw', key, alg, false, ['encrypt']); + return new Uint8Array(await sub.encrypt(alg, k, data)); + } + const padded = data.length % 16 === 0 ? data : (() => { const p = 16 - (data.length % 16); const out = new Uint8Array(data.length + p); out.set(data); for (let i = 0; i < p; i++) out[data.length + i] = p; return out; })(); + const blocks = padded.length / 16; + const out = new Uint8Array(padded.length); + let prev = new Uint8Array(iv); + for (let b = 0; b < blocks; b++) { + const block = new Uint8Array(padded.subarray(b * 16, (b + 1) * 16)); + for (let i = 0; i < 16; i++) block[i] ^= prev[i]; + const encrypted = aesEcbEncrypt(block, key); + out.set(encrypted, b * 16); + prev = encrypted; + } + return out; +} + +async function aesCbcDecrypt(data: Uint8Array, key: Uint8Array, iv: Uint8Array): Promise { + const sub = (globalThis as any).crypto?.subtle; + if (sub) { + const alg = { name: 'AES-CBC', iv }; + const k = await sub.importKey('raw', key, alg, false, ['decrypt']); + return new Uint8Array(await sub.decrypt(alg, k, data)); + } + const blocks = data.length / 16; + const out = new Uint8Array(data.length); + let prev = new Uint8Array(iv); + for (let b = 0; b < blocks; b++) { + const block = new Uint8Array(data.subarray(b * 16, (b + 1) * 16)); + const decrypted = aesEcbDecrypt(block, key); + for (let i = 0; i < 16; i++) out[b * 16 + i] = decrypted[i] ^ prev[i]; + prev = block; + } + const padLen = out[out.length - 1]; + if (padLen > 0 && padLen <= 16) return out.subarray(0, out.length - padLen); + return out; +} + +function rsaPublicEncrypt(data: Uint8Array, keyPem: string): Uint8Array { + const pemHeader = '-----BEGIN PUBLIC KEY-----'; + const pemFooter = '-----END PUBLIC KEY-----'; + let pemData = keyPem.substring(pemHeader.length); + pemData = pemData.substring(0, pemData.indexOf(pemFooter)).trim(); + const der = Uint8Array.from(atob(pemData), c => c.charCodeAt(0)); + // Parse RSA public key from DER (simple PKCS#1 / SubjectPublicKeyInfo) + let offset = 0; + function readLen(): number { + const b = der[offset++]; + if (b < 0x80) return b; + const n = b & 0x7f; + let len = 0; + for (let i = 0; i < n; i++) len = (len << 8) | der[offset++]; + return len; + } + function readTag(tag: number): Uint8Array { + if (der[offset++] !== tag) throw new Error('Invalid DER tag'); + const len = readLen(); + const buf = der.subarray(offset, offset + len); + offset += len; + return buf; + } + offset = 0; + if (der[offset++] !== 0x30) throw new Error('Invalid DER sequence'); + readLen(); + if (der[offset] === 0x30) { + readTag(0x30); + const algoOid = readTag(0x06); + offset++; readLen(); + if (der[offset++] !== 0x03) throw new Error('Invalid DER bit string'); + const bitLen = readLen(); + offset++; + if (der[offset++] !== 0x00) throw new Error('Invalid DER padding'); + if (der[offset++] !== 0x30) throw new Error('Invalid DER inner sequence'); + readLen(); + } + const nDer = readTag(0x02); + const eDer = readTag(0x02); + function bigFromBytes(bytes: Uint8Array): bigint { + let hex = ''; + for (const b of bytes) hex += b.toString(16).padStart(2, '0'); + return BigInt('0x' + hex); + } + const n = bigFromBytes(nDer); + const e = bigFromBytes(eDer); + const dataInt = bigFromBytes(data); + const result = dataInt ** e % n; + let hex = result.toString(16); + if (hex.length % 2 === 1) hex = '0' + hex; + const out = new Uint8Array(hex.length / 2); + for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.substr(i * 2, 2), 16); + return out; +} + // crypto 模块 const cryptoModule: any = { + constants: { + RSA_PKCS1_PADDING: 1, + RSA_NO_PADDING: 3, + RSA_PKCS1_OAEP_PADDING: 4, + RSA_PSS_SALTLEN_DIGEST: -1, + RSA_PSS_SALTLEN_MAX_SIGN: -2, + RSA_PSS_SALTLEN_AUTO: -1, + POINT_CONVERSION_COMPRESSED: 2, + POINT_CONVERSION_UNCOMPRESSED: 4, + POINT_CONVERSION_HYBRID: 6, + defaultCoreCipherList: '', + defaultCipherList: '', + }, createHash: function(algo: string) { - const chunks: string[] = []; + const chunks: Uint8Array[] = []; return { - update: function(chunk: any) { chunks.push(String(chunk)); return this; }, + update: function(chunk: any) { chunks.push(toBuffer(chunk)); return this; }, digest: function(encoding?: string) { - // 简化:不做真正的哈希,返回一个稳定的伪随机值 - const s = algo + ':' + chunks.join(''); - let h = 0; - for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0; - const hex = Math.abs(h).toString(16).padStart(16, '0') + Math.abs(h * 31).toString(16).padStart(16, '0'); - if (encoding === 'hex') return hex; - if (encoding === 'base64') return btoa(hex); - return hex; + const combined = chunks.length === 1 ? chunks[0] : (() => { const total = chunks.reduce((a, b) => a + b.length, 0); const out = new Uint8Array(total); let off = 0; for (const c of chunks) { out.set(c, off); off += c.length; } return out; })(); + return bufferToString(simpleHash(algo, combined), encoding); }, }; }, createHmac: function(algo: string, key: any) { - const chunks: string[] = []; + const chunks: Uint8Array[] = []; + const keyBuf = toBuffer(key); return { - update: function(chunk: any) { chunks.push(String(chunk)); return this; }, + update: function(chunk: any) { chunks.push(toBuffer(chunk)); return this; }, digest: function(encoding?: string) { - const s = 'hmac:' + algo + ':' + key + ':' + chunks.join(''); - let h = 0; - for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0; - const hex = Math.abs(h).toString(16).padStart(16, '0') + Math.abs(h * 17).toString(16).padStart(16, '0'); - if (encoding === 'hex') return hex; - if (encoding === 'base64') return btoa(hex); - return hex; + const data = chunks.length === 1 ? chunks[0] : (() => { const total = chunks.reduce((a, b) => a + b.length, 0); const out = new Uint8Array(total); let off = 0; for (const c of chunks) { out.set(c, off); off += c.length; } return out; })(); + const blockSize = algo === 'sha256' || algo === 'sha512' ? 128 : 64; + const k = keyBuf.length > blockSize ? simpleHash(algo, keyBuf) : keyBuf; + const iPadded = new Uint8Array(blockSize); + const oPadded = new Uint8Array(blockSize); + for (let i = 0; i < k.length; i++) { iPadded[i] = k[i] ^ 0x36; oPadded[i] = k[i] ^ 0x5c; } + for (let i = k.length; i < blockSize; i++) { iPadded[i] = 0x36; oPadded[i] = 0x5c; } + const inner = new Uint8Array(iPadded.length + data.length); + inner.set(iPadded); inner.set(data, iPadded.length); + const innerHash = simpleHash(algo, inner); + const outer = new Uint8Array(oPadded.length + innerHash.length); + outer.set(oPadded); outer.set(innerHash, oPadded.length); + return bufferToString(simpleHash(algo, outer), encoding); }, }; }, @@ -328,7 +646,7 @@ const cryptoModule: any = { for (let i = 0; i < n; i++) s += String.fromCharCode(arr[i]); return btoa(s); } - return String.fromCharCode.apply(null, arr as any); + return new TextDecoder().decode(arr); }, }; if (cb) { setTimeout(() => cb(null, buf), 0); return undefined; } @@ -341,6 +659,46 @@ const cryptoModule: any = { return v.toString(16); }); }, + createCipheriv: function(algo: string, key: any, iv: any) { + const keyBuf = toBuffer(key); + const ivBuf = iv ? toBuffer(iv) : new Uint8Array(0); + return { + update: function(data: any) { + const dataBuf = toBuffer(data); + if (algo === 'aes-128-ecb') return aesEcbEncrypt(dataBuf, keyBuf); + return dataBuf; + }, + final: function() { return new Uint8Array(0); }, + }; + }, + createDecipheriv: function(algo: string, key: any, iv: any) { + const keyBuf = toBuffer(key); + const ivBuf = iv ? toBuffer(iv) : new Uint8Array(0); + return { + update: function(data: any) { + const dataBuf = toBuffer(data); + if (algo === 'aes-128-ecb') return aesEcbDecrypt(dataBuf, keyBuf); + return dataBuf; + }, + final: function() { return new Uint8Array(0); }, + }; + }, + publicEncrypt: function(options: any, data: any) { + const keyPem = typeof options === 'string' ? options : options.key; + const dataBuf = toBuffer(data); + return rsaPublicEncrypt(dataBuf, keyPem); + }, + privateDecrypt: function() { return new Uint8Array(0); }, + privateEncrypt: function() { return new Uint8Array(0); }, + createSign: function() { return { update: function() { return this; }, sign: function() { return new Uint8Array(0); } }; }, + createVerify: function() { return { update: function() { return this; }, verify: function() { return true; } }; }, + getCiphers: () => ['aes-128-cbc', 'aes-128-ecb'], + getHashes: () => ['md5', 'sha1', 'sha256'], + pbkdf2: function() {}, + pbkdf2Sync: function() { return new Uint8Array(0); }, + createDiffieHellman: function() { return { generateKeys: () => new Uint8Array(0), computeSecret: () => new Uint8Array(0), getPrime: () => new Uint8Array(0), getGenerator: () => new Uint8Array(0) }; }, + createECDH: function() { return { generateKeys: () => new Uint8Array(0), computeSecret: () => new Uint8Array(0), getPublicKey: () => new Uint8Array(0) }; }, + timingSafeEqual: function(a: any, b: any) { const al = toBuffer(a); const bl = toBuffer(b); if (al.length !== bl.length) return false; let d = 0; for (let i = 0; i < al.length; i++) d |= al[i] ^ bl[i]; return d === 0; }, }; // 完整的 polyfill 注册表 @@ -499,6 +857,7 @@ export function createNodePolyfills(): Record { resolve: (_host2: string, cb: (err: Error | null, addrs: string[]) => void) => { setTimeout(() => cb(null, ['127.0.0.1']), 0); }, }, buffer: bufferModule, + // 'Buffer' is already an alias for bufferModule, but some plugins require('buffer').Buffer Buffer: bufferModule, string_decoder: { StringDecoder: function() { diff --git a/src/plugins/pluginManager.ts b/src/plugins/pluginManager.ts index 64a0018..e14cfdc 100644 --- a/src/plugins/pluginManager.ts +++ b/src/plugins/pluginManager.ts @@ -20,9 +20,21 @@ const BUILTIN_PLUGINS: BuiltinPluginDef[] = [ ]; // 支持的插件源 URL 前缀 - 按优先级排序 -const PLUGIN_SOURCE_PREFIXES: string[] = [ - '/plugins/', // 本地构建产物 /plugins/ 目录(通过 install.sh 下载) -]; +function getPluginSourcePrefixes(): string[] { + // 优先从 localStorage 读取自定义源(用户可以配置) + try { + const custom = JSON.parse(localStorage.getItem('qz-plugin-sources') || '[]'); + if (Array.isArray(custom) && custom.length > 0) { + return custom.map((s: string) => s.endsWith('/') ? s : s + '/'); + } + } catch { /* noop */ } + return [ + '/plugins/', // 本地构建产物 /plugins/ 目录(通过 install.sh 下载) + 'https://cdn.jsdelivr.net/gh/your-repo/plugins@latest/', // CDN 回退 + ]; +} + +const PLUGIN_SOURCE_PREFIXES = getPluginSourcePrefixes(); interface LoadedPlugin { id: string; @@ -117,6 +129,34 @@ export class PluginManager { ]; } + // ========== 插件环境变量管理 ========== + getEnvDefs(id: string): { key: string; name: string; description?: string }[] { + const lp = this.plugins.get(id); + if (lp && lp.module && lp.module.pluginInfo && lp.module.pluginInfo.env) { + return lp.module.pluginInfo.env as any[]; + } + return []; + } + + setEnvVar(key: string, value: string): void { + try { + const envData = JSON.parse(localStorage.getItem('qz-plugin-env') || '{}'); + envData[key] = value; + localStorage.setItem('qz-plugin-env', JSON.stringify(envData)); + } catch { /* noop */ } + } + + getEnvVar(key: string): string { + try { + const envData = JSON.parse(localStorage.getItem('qz-plugin-env') || '{}'); + return envData[key] || ''; + } catch { return ''; } + } + + getAllEnvVars(): Record { + try { return JSON.parse(localStorage.getItem('qz-plugin-env') || '{}'); } catch { return {}; } + } + // ========== 插件代码加载 ========== /** * 从 JS 代码字符串加载插件。 @@ -293,7 +333,8 @@ export class PluginManager { async loadBuiltin(def: BuiltinPluginDef): Promise { if (this.has(def.id)) return true; - for (const prefix of PLUGIN_SOURCE_PREFIXES) { + const prefixes = getPluginSourcePrefixes(); + for (const prefix of prefixes) { if (await this.loadFromUrl(prefix + def.fileName, def.id, 'built-in')) { // 拉成功后用配置里的 name/desc 更新元信息(插件内的 info 可能为英文) const lp = this.plugins.get(def.id); @@ -508,6 +549,12 @@ export class PluginManager { } }; + const pluginEnv: Record = {}; + try { + const savedEnv = JSON.parse(localStorage.getItem('qz-plugin-env') || '{}'); + Object.assign(pluginEnv, savedEnv); + } catch { /* noop */ } + const fakeGlobal: any = { process: processObj, Buffer: BufferCtor, @@ -516,11 +563,11 @@ export class PluginManager { exports: moduleObj.exports, __dirname: '/', __filename: '/plugin.js', - // Node 全局(alias 到浏览器真实全局,以保持 window.location.href 正常工作) global: globalThis, globalThis: globalThis, self: globalThis, root: globalThis, + env: pluginEnv, console, setTimeout: setTimeout as any, clearTimeout, diff --git a/src/utils/localMusic.ts b/src/utils/localMusic.ts new file mode 100644 index 0000000..669ff1f --- /dev/null +++ b/src/utils/localMusic.ts @@ -0,0 +1,177 @@ +import type { Song, SongType } from '../types/song'; + +export interface ScannedTrack { + id: string; + name: string; + artist: string; + album: string; + duration: number; + file: File; + fileHandle?: FileSystemFileHandle; + url?: string; +} + +const AUDIO_EXTS = new Set(['.mp3', '.flac', '.wav', '.ogg', '.aac', '.m4a', '.wma', '.ape', '.opus']); + +function isAudioFile(name: string): boolean { + const dot = name.lastIndexOf('.'); + if (dot === -1) return false; + return AUDIO_EXTS.has(name.slice(dot).toLowerCase()); +} + +function parseFileName(name: string): { title: string; artist: string } { + const dot = name.lastIndexOf('.'); + const base = dot !== -1 ? name.slice(0, dot) : name; + const separators = [' - ', ' – ', ' — ', ' -', '-', ' / ', '/']; + for (const sep of separators) { + const idx = base.indexOf(sep); + if (idx > 0 && idx < base.length - sep.length) { + return { artist: base.slice(0, idx).trim(), title: base.slice(idx + sep.length).trim() }; + } + } + return { title: base, artist: '' }; +} + +function genId(): string { + return 'local-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8); +} + +async function getDuration(file: File): Promise { + return new Promise((resolve) => { + const url = URL.createObjectURL(file); + const audio = new Audio(); + audio.preload = 'metadata'; + const timer = setTimeout(() => { audio.src = ''; URL.revokeObjectURL(url); resolve(0); }, 5000); + audio.onloadedmetadata = () => { + clearTimeout(timer); + const dur = Math.round(audio.duration * 1000); + audio.src = ''; + URL.revokeObjectURL(url); + resolve(dur); + }; + audio.onerror = () => { clearTimeout(timer); audio.src = ''; URL.revokeObjectURL(url); resolve(0); }; + audio.src = url; + audio.load(); + }); +} + +export async function scanFile(file: File, fileHandle?: FileSystemFileHandle): Promise { + if (!isAudioFile(file.name)) return null; + const parsed = parseFileName(file.name); + const duration = await getDuration(file); + return { + id: genId(), + name: parsed.title || file.name, + artist: parsed.artist || '未知歌手', + album: '', + duration, + file, + fileHandle, + }; +} + +export async function scanDirectory(dirHandle: FileSystemDirectoryHandle, onProgress?: (name: string) => void): Promise { + const results: ScannedTrack[] = []; + const entries: [string, FileSystemHandle][] = []; + for await (const entry of (dirHandle as any).entries()) { + entries.push(entry); + } + for (const [name, handle] of entries) { + if (onProgress) onProgress(name); + if (handle.kind === 'file') { + const fileHandle = handle as FileSystemFileHandle; + const file = await fileHandle.getFile(); + const track = await scanFile(file, fileHandle); + if (track) results.push(track); + } else if (handle.kind === 'directory') { + const sub = await scanDirectory(handle as FileSystemDirectoryHandle, onProgress); + results.push(...sub); + } + } + return results; +} + +export function trackToSong(track: ScannedTrack): Song { + return { + id: track.id, + name: track.name, + artist: track.artist, + picUrl: '', + url: track.url || '', + duration: formatDuration(track.duration), + source: 'local', + type: 'Local' as SongType, + albumName: track.album || null, + quality: 'standard', + }; +} + +export function formatDuration(ms: number): string { + if (!ms || ms <= 0) return '0:00'; + const totalSec = Math.floor(ms / 1000); + const min = Math.floor(totalSec / 60); + const sec = totalSec % 60; + return `${min}:${sec.toString().padStart(2, '0')}`; +} + +const DB_NAME = 'qz-local-music'; +const DB_VERSION = 1; +const STORE_NAME = 'tracks'; + +function openDB(): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, DB_VERSION); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME, { keyPath: 'id' }); + } + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +export async function saveTracks(tracks: ScannedTrack[]): Promise { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite'); + const store = tx.objectStore(STORE_NAME); + for (const t of tracks) { + store.put({ id: t.id, name: t.name, artist: t.artist, album: t.album, duration: t.duration }); + } + tx.oncomplete = () => { db.close(); resolve(); }; + tx.onerror = () => { db.close(); reject(tx.error); }; + }); +} + +export async function loadTracks(): Promise { + try { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readonly'); + const store = tx.objectStore(STORE_NAME); + const req = store.getAll(); + req.onsuccess = () => { db.close(); resolve(req.result || []); }; + req.onerror = () => { db.close(); reject(req.error); }; + }); + } catch { + return []; + } +} + +export async function clearSavedTracks(): Promise { + try { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite'); + tx.objectStore(STORE_NAME).clear(); + tx.oncomplete = () => { db.close(); resolve(); }; + tx.onerror = () => { db.close(); reject(tx.error); }; + }); + } catch { /* noop */ } +} + +export function supportsDirectoryPicker(): boolean { + return typeof (window as any).showDirectoryPicker === 'function'; +} diff --git a/src/utils/songUtils.ts b/src/utils/songUtils.ts index 310d330..326de0f 100644 --- a/src/utils/songUtils.ts +++ b/src/utils/songUtils.ts @@ -9,10 +9,9 @@ export function formatDuration(ms: number): string { } /** - * 将 PC 原版插件搜索结果转换为 Web 版 Song 格式 - * 兼容 PC 版 songUtils.ts 的 transformSearchSong + * 将 PC/Android 原版插件搜索结果转换为 Web 版 Song 格式 * - * PC 原版搜索结果字段: + * PC 原版 v2 字段: * - songmid: 歌曲 ID * - name: 歌曲名 * - singer: 歌手名 @@ -22,21 +21,41 @@ export function formatDuration(ms: number): string { * - albumId: 专辑 ID * - albumName: 专辑名 * - types: 音质映射 + * + * v3 插件字段: + * - id: 歌曲 ID + * - name: 歌曲名 + * - artists: 歌手名 + * - pic: 封面图 + * - interval: 时长(毫秒字符串) + * - source: 来源标识 + * - albumName: 专辑名 + * - albumId: 专辑 ID + * - qualities: 音质映射 */ export function transformSearchSong(raw: any): Song { + const id = String(raw.songmid || raw.id || raw.songId || ''); + const name = raw.name || '未知歌曲'; + const artist = raw.singer || raw.artist || raw.artists || '未知歌手'; + const picUrl = raw.img || raw.picUrl || raw.pic || raw.mPic || raw.sPic || raw.m_img || raw.s_img || ''; + const interval = raw.interval ? Number(raw.interval) : raw.duration ? Number(raw.duration) : 0; + const albumName = raw.albumName || raw.album || null; + const albumId = raw.albumId ? String(raw.albumId) : null; + const types = raw.types || raw.qualities || undefined; + return { - id: String(raw.songmid || raw.id || ''), - name: raw.name || '未知歌曲', - artist: raw.singer || raw.artist || '未知歌手', - picUrl: raw.img || raw.m_img || raw.s_img || raw.picUrl || '', + id, + name, + artist, + picUrl, url: '', - duration: formatDuration(raw.interval ? Number(raw.interval) : 0), + duration: formatDuration(interval), source: raw.source || '', - albumId: raw.albumId ? String(raw.albumId) : null, - albumName: raw.albumName || null, + albumId, + albumName, type: 'Remote', quality: 'auto', - types: raw.types || undefined, + types, }; } diff --git a/src/views/LocalMusic.vue b/src/views/LocalMusic.vue index 3f65038..f0cbf04 100644 --- a/src/views/LocalMusic.vue +++ b/src/views/LocalMusic.vue @@ -1,18 +1,174 @@