|
// 小米IoT平台设备控制 Cloudflare Worker |
|
|
|
addEventListener('fetch', event => { |
|
event.respondWith(handleRequest(event.request)) |
|
}) |
|
|
|
/** |
|
* 处理 HTTP 请求 |
|
* 1. 仅接受以 /app/ 开头的 POST 请求 |
|
* 2. 解析 body 中的 userId / serviceToken / deviceId / securityToken 以及除去这些字段后的其余内容 |
|
* 3. 构造米家接口所需签名并转发到 https://api.io.mi.com 保留全部响应 |
|
*/ |
|
async function handleRequest(request) { |
|
const corsHeaders = { |
|
'Access-Control-Allow-Origin': '*', |
|
'Access-Control-Allow-Methods': 'POST, OPTIONS', |
|
'Access-Control-Allow-Headers': 'Content-Type' |
|
} |
|
|
|
// 预检 |
|
if (request.method === 'OPTIONS') { |
|
return new Response(null, { headers: corsHeaders }) |
|
} |
|
|
|
if (request.method !== 'POST') { |
|
return new Response('仅支持 POST', { |
|
status: 405, |
|
headers: { ...corsHeaders, 'Content-Type': 'text/plain; charset=UTF-8' } |
|
}) |
|
} |
|
|
|
const url = new URL(request.url) |
|
const pathname = url.pathname |
|
|
|
if (!pathname.startsWith('/app/')) { |
|
return new Response('路径必须以 /app/ 开头', { |
|
status: 400, |
|
headers: { ...corsHeaders, 'Content-Type': 'text/plain; charset=UTF-8' } |
|
}) |
|
} |
|
|
|
try { |
|
// 读取并解析请求体 |
|
const bodyJson = await request.json() |
|
const { |
|
userId, |
|
serviceToken, |
|
deviceId, |
|
securityToken, |
|
data: dataField |
|
} = bodyJson || {} |
|
|
|
if (!userId || !serviceToken || !deviceId || !securityToken) { |
|
return new Response('缺少身份验证信息', { |
|
status: 400, |
|
headers: { ...corsHeaders, 'Content-Type': 'text/plain; charset=UTF-8' } |
|
}) |
|
} |
|
|
|
if (dataField === undefined) { |
|
return new Response('缺少 data 字段', { |
|
status: 400, |
|
headers: { ...corsHeaders, 'Content-Type': 'text/plain; charset=UTF-8' } |
|
}) |
|
} |
|
|
|
// 计算签名 |
|
const miPath = pathname.replace(/^\/app/, '') // 例如 /miotspec/prop/set |
|
const nonce = generateNonce() |
|
const signedNonce = await generateSignedNonce(securityToken, nonce) |
|
const dataString = typeof dataField === 'string' ? dataField : JSON.stringify(dataField) |
|
const signature = await generateSignature(miPath, signedNonce, nonce, dataString) |
|
|
|
// 组装请求参数 |
|
const formParams = new URLSearchParams({ |
|
_nonce: nonce, |
|
data: dataString, |
|
signature |
|
}) |
|
|
|
// 构造请求头 |
|
const headers = { |
|
'User-Agent': 'APP/com.xiaomi.mihome APPV/6.0.103 iosPassportSDK/3.9.0 iOS/14.4 miHSTS', |
|
'x-xiaomi-protocal-flag-cli': 'PROTOCAL-HTTP2', |
|
'Content-Type': 'application/x-www-form-urlencoded', |
|
'Cookie': `serviceToken=${serviceToken}; userId=${userId}; PassportDeviceId=${deviceId}` |
|
} |
|
|
|
// 转发到米家接口 |
|
const remoteResponse = await fetch(`https://api.io.mi.com${pathname}`, { |
|
method: 'POST', |
|
headers, |
|
body: formParams |
|
}) |
|
|
|
// 构造返回,保持原始内容,仅追加 CORS 头 |
|
const respHeaders = new Headers(remoteResponse.headers) |
|
Object.entries(corsHeaders).forEach(([k, v]) => respHeaders.set(k, v)) |
|
|
|
return new Response(remoteResponse.body, { |
|
status: remoteResponse.status, |
|
headers: respHeaders |
|
}) |
|
} catch (err) { |
|
return new Response('处理请求时出错: ' + err.message, { |
|
status: 500, |
|
headers: { ...corsHeaders, 'Content-Type': 'text/plain; charset=UTF-8' } |
|
}) |
|
} |
|
} |
|
|
|
// ================= 工具函数 ================= |
|
function generateNonce() { |
|
const randomBytes = new Uint8Array(8) |
|
crypto.getRandomValues(randomBytes) |
|
const timeBytes = new Uint8Array(4) |
|
const time = Math.floor(Date.now() / 1000 / 60) |
|
timeBytes[0] = (time >> 24) & 0xff |
|
timeBytes[1] = (time >> 16) & 0xff |
|
timeBytes[2] = (time >> 8) & 0xff |
|
timeBytes[3] = time & 0xff |
|
const combined = new Uint8Array(12) |
|
combined.set(randomBytes) |
|
combined.set(timeBytes, 8) |
|
return btoa(String.fromCharCode.apply(null, combined)) |
|
} |
|
|
|
async function generateSignedNonce(secret, nonce) { |
|
const secretBytes = base64ToUint8Array(secret) |
|
const nonceBytes = base64ToUint8Array(nonce) |
|
const combined = new Uint8Array(secretBytes.length + nonceBytes.length) |
|
combined.set(secretBytes) |
|
combined.set(nonceBytes, secretBytes.length) |
|
const hash = await crypto.subtle.digest('SHA-256', combined) |
|
return btoa(String.fromCharCode.apply(null, new Uint8Array(hash))) |
|
} |
|
|
|
async function generateSignature(url, signedNonce, nonce, data) { |
|
const signString = `${url}&${signedNonce}&${nonce}&data=${data}` |
|
const keyBytes = base64ToUint8Array(signedNonce) |
|
const key = await crypto.subtle.importKey( |
|
'raw', |
|
keyBytes, |
|
{ name: 'HMAC', hash: { name: 'SHA-256' } }, |
|
false, |
|
['sign'] |
|
) |
|
const signature = await crypto.subtle.sign( |
|
'HMAC', |
|
key, |
|
new TextEncoder().encode(signString) |
|
) |
|
return btoa(String.fromCharCode.apply(null, new Uint8Array(signature))) |
|
} |
|
|
|
function base64ToUint8Array(base64) { |
|
const binary = atob(base64) |
|
const bytes = new Uint8Array(binary.length) |
|
for (let i = 0; i < binary.length; i++) { |
|
bytes[i] = binary.charCodeAt(i) |
|
} |
|
return bytes |
|
} |