优化连接过期验证与戒色信息-1
This commit is contained in:
@@ -27,6 +27,16 @@ export function resolveCloudtentaclesConfig(overrides = {}) {
|
||||
vnBindUrlPath: '/vn/bind_url',
|
||||
vnBindInfoPath: '/vn/bind_info',
|
||||
vnBackPath: '/vn/back',
|
||||
bindUrlTtlSeconds: 300,
|
||||
bindUrlProbeTimeoutMs: 5000,
|
||||
bindUrlProbeUserAgent: '',
|
||||
bindUrlProbeEndpoint: 'https://comm.ams.game.qq.com/ide/',
|
||||
bindUrlProbeChartId: '323794',
|
||||
bindUrlProbeSubChartId: '323794',
|
||||
bindUrlProbeIdeToken: 'z90Syo',
|
||||
bindUrlProbeActivityUrl: 'http%3A%2F%2Fgp.qq.com%2Fcp%2Fa20240828cmcc%2F',
|
||||
bindUrlProbeReferer: 'https://gp.qq.com/',
|
||||
bindUrlProbeExtraCookie: '',
|
||||
publicKeyPem: '',
|
||||
clientSource: 'ct-client',
|
||||
deviceId: '-',
|
||||
@@ -60,6 +70,16 @@ export function resolveCloudtentaclesConfig(overrides = {}) {
|
||||
vnBindUrlPath: normalizePath(overrides.vnBindUrlPath || baseConfig.vnBindUrlPath, '/vn/bind_url'),
|
||||
vnBindInfoPath: normalizePath(overrides.vnBindInfoPath || baseConfig.vnBindInfoPath, '/vn/bind_info'),
|
||||
vnBackPath: normalizePath(overrides.vnBackPath || baseConfig.vnBackPath, '/vn/back'),
|
||||
bindUrlTtlSeconds: normalizePositiveInteger(overrides.bindUrlTtlSeconds || baseConfig.bindUrlTtlSeconds, 300),
|
||||
bindUrlProbeTimeoutMs: normalizePositiveInteger(overrides.bindUrlProbeTimeoutMs || baseConfig.bindUrlProbeTimeoutMs, 5000),
|
||||
bindUrlProbeUserAgent: String(overrides.bindUrlProbeUserAgent || baseConfig.bindUrlProbeUserAgent || '').trim(),
|
||||
bindUrlProbeEndpoint: String(overrides.bindUrlProbeEndpoint || baseConfig.bindUrlProbeEndpoint || 'https://comm.ams.game.qq.com/ide/').trim(),
|
||||
bindUrlProbeChartId: String(overrides.bindUrlProbeChartId || baseConfig.bindUrlProbeChartId || '323794').trim(),
|
||||
bindUrlProbeSubChartId: String(overrides.bindUrlProbeSubChartId || baseConfig.bindUrlProbeSubChartId || '323794').trim(),
|
||||
bindUrlProbeIdeToken: String(overrides.bindUrlProbeIdeToken || baseConfig.bindUrlProbeIdeToken || 'z90Syo').trim(),
|
||||
bindUrlProbeActivityUrl: String(overrides.bindUrlProbeActivityUrl || baseConfig.bindUrlProbeActivityUrl || 'http%3A%2F%2Fgp.qq.com%2Fcp%2Fa20240828cmcc%2F').trim(),
|
||||
bindUrlProbeReferer: String(overrides.bindUrlProbeReferer || baseConfig.bindUrlProbeReferer || 'https://gp.qq.com/').trim(),
|
||||
bindUrlProbeExtraCookie: String(overrides.bindUrlProbeExtraCookie || baseConfig.bindUrlProbeExtraCookie || '').trim(),
|
||||
publicKeyPem: normalizePem(overrides.publicKeyPem || baseConfig.publicKeyPem),
|
||||
clientSource: String(overrides.clientSource || baseConfig.clientSource || 'ct-client').trim() || 'ct-client',
|
||||
deviceId: String(overrides.deviceId ?? baseConfig.deviceId ?? '-').trim() || '-',
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
// @ts-check
|
||||
|
||||
import http from 'node:http'
|
||||
import https from 'node:https'
|
||||
|
||||
import { createHttpError } from '../../../utils/http.js'
|
||||
import { cloudtentaclesRequest } from './http-client.js'
|
||||
import { resolveCloudtentaclesConfig } from './shared.js'
|
||||
|
||||
const BIND_URL_PROBE_MAX_BODY_BYTES = 64 * 1024
|
||||
const AMS_SIGNATURE_EXPIRED_CODE = '99998'
|
||||
|
||||
export async function listCloudtentaclesVirtualNumbers(payload = {}) {
|
||||
const token = requireToken(payload.token, 'cloudtentacles 虚拟号列表缺少 token', 'cloudtentacles_vn_list_missing_token')
|
||||
const key = requireKey(payload.key, 'cloudtentacles 虚拟号列表缺少 key', 'cloudtentacles_vn_list_missing_key')
|
||||
@@ -159,6 +165,80 @@ export async function getCloudtentaclesBindUrl(payload = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function probeCloudtentaclesBindUrl(payload = {}) {
|
||||
const bindUrl = String(payload.bindUrl || '').trim()
|
||||
if (!bindUrl) {
|
||||
return {
|
||||
valid: false,
|
||||
status: 0,
|
||||
finalUrl: '',
|
||||
reason: 'empty_url',
|
||||
}
|
||||
}
|
||||
|
||||
const config = resolveCloudtentaclesConfig(payload)
|
||||
const signatureParams = extractBindUrlSignatureParams(bindUrl)
|
||||
if (!signatureParams) {
|
||||
return {
|
||||
valid: false,
|
||||
expired: false,
|
||||
status: 0,
|
||||
finalUrl: bindUrl,
|
||||
reason: 'missing_signature_params',
|
||||
roleInfo: normalizeAmsBindRoleInfo(null),
|
||||
raw: null,
|
||||
}
|
||||
}
|
||||
|
||||
const timeoutMs = Number(payload.timeoutMs || config.bindUrlProbeTimeoutMs || 5000)
|
||||
const userAgent = String(payload.userAgent || config.bindUrlProbeUserAgent || '').trim()
|
||||
const endpoint = String(payload.endpoint || config.bindUrlProbeEndpoint || 'https://comm.ams.game.qq.com/ide/').trim()
|
||||
const startedAt = Date.now()
|
||||
|
||||
try {
|
||||
const response = await requestAmsIdeProbe(endpoint, {
|
||||
timeoutMs,
|
||||
body: buildAmsIdeProbeBody(signatureParams, config, payload),
|
||||
headers: buildAmsIdeProbeHeaders(signatureParams, config, {
|
||||
userAgent,
|
||||
}),
|
||||
})
|
||||
const raw = tryParseJson(response.bodyText)
|
||||
const expired = isAmsSignatureExpired(raw)
|
||||
const bindInfo = extractAmsBindInfo(raw)
|
||||
const roleInfo = normalizeAmsBindRoleInfo(bindInfo)
|
||||
const ok = response.status >= 200
|
||||
&& response.status < 300
|
||||
&& !expired
|
||||
&& isAmsBusinessOk(raw)
|
||||
|
||||
return {
|
||||
valid: ok,
|
||||
expired,
|
||||
status: response.status,
|
||||
finalUrl: endpoint,
|
||||
ret: String(raw?.ret ?? raw?.iRet ?? ''),
|
||||
iRet: String(raw?.iRet ?? raw?.jData?.iRet ?? ''),
|
||||
message: String(raw?.sMsg || raw?.jData?.sMsg || ''),
|
||||
reason: expired ? 'signature_expired' : (ok ? 'ok' : 'ams_business_not_ok'),
|
||||
roleInfo,
|
||||
raw,
|
||||
durationMs: Date.now() - startedAt,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
expired: false,
|
||||
status: 0,
|
||||
finalUrl: bindUrl,
|
||||
reason: error instanceof Error ? error.message : String(error || 'probe_failed'),
|
||||
roleInfo: normalizeAmsBindRoleInfo(null),
|
||||
raw: null,
|
||||
durationMs: Date.now() - startedAt,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCloudtentaclesBindInfo(payload = {}) {
|
||||
const token = requireToken(payload.token, 'cloudtentacles 获取绑定信息缺少 token', 'cloudtentacles_vn_bind_info_missing_token')
|
||||
const key = requireKey(payload.key, 'cloudtentacles 获取绑定信息缺少 key', 'cloudtentacles_vn_bind_info_missing_key')
|
||||
@@ -275,6 +355,242 @@ function parseBindInfo(value) {
|
||||
return tryParseJson(bindInfoText) || bindInfoText || null
|
||||
}
|
||||
|
||||
async function requestAmsIdeProbe(endpoint, options = {}) {
|
||||
const url = new URL(endpoint)
|
||||
if (!['http:', 'https:'].includes(url.protocol)) {
|
||||
throw new Error('unsupported_ams_probe_protocol')
|
||||
}
|
||||
|
||||
const timeoutMs = Number(options.timeoutMs || 5000)
|
||||
const isHttps = url.protocol === 'https:'
|
||||
const transport = isHttps ? https : http
|
||||
const body = options.body instanceof URLSearchParams ? options.body.toString() : String(options.body || '')
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = transport.request(url, {
|
||||
method: 'POST',
|
||||
rejectUnauthorized: false,
|
||||
headers: {
|
||||
...normalizeHeaderMap(options.headers),
|
||||
'content-length': Buffer.byteLength(body),
|
||||
'accept-encoding': 'identity',
|
||||
},
|
||||
}, (response) => {
|
||||
const chunks = []
|
||||
let receivedBytes = 0
|
||||
|
||||
response.on('data', (chunk) => {
|
||||
if (receivedBytes >= BIND_URL_PROBE_MAX_BODY_BYTES) {
|
||||
return
|
||||
}
|
||||
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
||||
const remainingBytes = BIND_URL_PROBE_MAX_BODY_BYTES - receivedBytes
|
||||
chunks.push(buffer.length > remainingBytes ? buffer.subarray(0, remainingBytes) : buffer)
|
||||
receivedBytes += Math.min(buffer.length, remainingBytes)
|
||||
})
|
||||
|
||||
response.on('end', () => {
|
||||
clearTimeout(timer)
|
||||
resolve({
|
||||
status: Number(response.statusCode || 0),
|
||||
headers: createHeaderAdapter(response.headers),
|
||||
bodyText: Buffer.concat(chunks).toString('utf8'),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
request.destroy(new Error('bind_url_probe_timeout'))
|
||||
}, timeoutMs)
|
||||
|
||||
request.on('error', (error) => {
|
||||
clearTimeout(timer)
|
||||
reject(error)
|
||||
})
|
||||
|
||||
request.write(body)
|
||||
request.end()
|
||||
})
|
||||
}
|
||||
|
||||
function createHeaderAdapter(headers) {
|
||||
const normalized = new Map()
|
||||
|
||||
for (const [key, value] of Object.entries(headers || {})) {
|
||||
if (Array.isArray(value)) {
|
||||
normalized.set(String(key || '').toLowerCase(), value.join(', '))
|
||||
continue
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
normalized.set(String(key || '').toLowerCase(), value)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
get(name) {
|
||||
return normalized.get(String(name || '').toLowerCase()) || null
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function buildAmsIdeProbeBody(signatureParams, config, payload = {}) {
|
||||
const body = new URLSearchParams()
|
||||
const chartId = String(payload.chartId || config.bindUrlProbeChartId || '323794').trim()
|
||||
const subChartId = String(payload.subChartId || config.bindUrlProbeSubChartId || chartId).trim()
|
||||
|
||||
body.set('iChartId', chartId)
|
||||
body.set('iSubChartId', subChartId)
|
||||
body.set('sIdeToken', String(payload.ideToken || config.bindUrlProbeIdeToken || 'z90Syo').trim())
|
||||
body.set('e_code', '0')
|
||||
body.set('g_code', '0')
|
||||
body.set('eas_url', String(payload.easUrl || config.bindUrlProbeActivityUrl || 'http%3A%2F%2Fgp.qq.com%2Fcp%2Fa20240828cmcc%2F').trim())
|
||||
body.set('eas_refer', String(payload.easRefer || `http%3A%2F%2Fnoreferrer%2F%3Freqid%3D${Date.now()}%26version%3D27`).trim())
|
||||
body.set('sMiloTag', String(payload.miloTag || `AMS-gp-${Date.now()}`).trim())
|
||||
body.set('userId', signatureParams.userId)
|
||||
body.set('timestamp', signatureParams.timestamp)
|
||||
body.set('nonce', signatureParams.nonce)
|
||||
body.set('sign', signatureParams.sign)
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
function buildAmsIdeProbeHeaders(signatureParams, config, options = {}) {
|
||||
const userAgent = String(options.userAgent || '').trim()
|
||||
const referer = String(config.bindUrlProbeReferer || 'https://gp.qq.com/').trim()
|
||||
|
||||
return {
|
||||
accept: 'application/json, text/plain, */*',
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
'accept-language': 'zh-CN,zh;q=0.9,fr;q=0.8,de;q=0.7,en;q=0.6',
|
||||
'cache-control': 'no-cache',
|
||||
origin: 'https://gp.qq.com',
|
||||
pragma: 'no-cache',
|
||||
referer,
|
||||
'sec-ch-ua': '""',
|
||||
'sec-ch-ua-mobile': '?1',
|
||||
'sec-ch-ua-platform': '""',
|
||||
'sec-fetch-dest': 'empty',
|
||||
'sec-fetch-mode': 'cors',
|
||||
'sec-fetch-site': 'same-site',
|
||||
...(userAgent ? { 'user-agent': userAgent } : {}),
|
||||
cookie: buildAmsIdeProbeCookie(signatureParams, config),
|
||||
}
|
||||
}
|
||||
|
||||
function buildAmsIdeProbeCookie(signatureParams, config) {
|
||||
const tokenParams = `?userId=${signatureParams.userId}×tamp=${signatureParams.timestamp}&nonce=${signatureParams.nonce}&sign=${signatureParams.sign}`
|
||||
const cookies = [
|
||||
`tokenParams=${encodeURIComponent(tokenParams)}`,
|
||||
'gpqqcomrouteLine=a20240828cmcc_a20240828cmcc_a20240828cmcc_a20240828cmcc_a20240828cmcc_a20240828cmcc_a20240828cmcc',
|
||||
String(config.bindUrlProbeExtraCookie || '').trim(),
|
||||
].filter(Boolean)
|
||||
|
||||
return cookies.join('; ')
|
||||
}
|
||||
|
||||
function extractBindUrlSignatureParams(bindUrl) {
|
||||
try {
|
||||
const url = new URL(bindUrl)
|
||||
const params = new URLSearchParams(url.search)
|
||||
const hashQueryIndex = String(url.hash || '').indexOf('?')
|
||||
if (hashQueryIndex >= 0) {
|
||||
const hashParams = new URLSearchParams(String(url.hash || '').slice(hashQueryIndex + 1))
|
||||
for (const [key, value] of hashParams.entries()) {
|
||||
if (!params.has(key)) {
|
||||
params.set(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
const tokenParams = String(params.get('tokenParams') || '').trim()
|
||||
if (tokenParams) {
|
||||
const nested = new URLSearchParams(tokenParams.startsWith('?') ? tokenParams.slice(1) : tokenParams)
|
||||
for (const [key, value] of nested.entries()) {
|
||||
if (!params.has(key)) {
|
||||
params.set(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const userId = String(params.get('userId') || '').trim()
|
||||
const timestamp = String(params.get('timestamp') || '').trim()
|
||||
const nonce = String(params.get('nonce') || '').trim()
|
||||
const sign = String(params.get('sign') || '').trim()
|
||||
|
||||
if (!userId || !timestamp || !nonce || !sign) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
userId,
|
||||
timestamp,
|
||||
nonce,
|
||||
sign,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function isAmsBusinessOk(raw) {
|
||||
const ret = String(raw?.ret ?? '').trim()
|
||||
const iRet = String(raw?.iRet ?? raw?.jData?.iRet ?? '').trim()
|
||||
return ret === '0' || iRet === '0'
|
||||
}
|
||||
|
||||
function isAmsSignatureExpired(raw) {
|
||||
const codes = [
|
||||
raw?.ret,
|
||||
raw?.iRet,
|
||||
raw?.jData?.ret,
|
||||
raw?.jData?.iRet,
|
||||
raw?.jData?.jData?.iRet,
|
||||
raw?.arrErrNodeInfo?.errorCode,
|
||||
raw?.jData?.arrErrNodeInfo?.errorCode,
|
||||
].map((value) => String(value ?? '').trim())
|
||||
|
||||
const messages = [
|
||||
raw?.sMsg,
|
||||
raw?.jData?.sMsg,
|
||||
raw?.jData?.jData?.sMsg,
|
||||
].map((value) => String(value ?? '').trim())
|
||||
|
||||
return codes.includes(AMS_SIGNATURE_EXPIRED_CODE) || messages.some((message) => message.includes('签名已过期'))
|
||||
}
|
||||
|
||||
function extractAmsBindInfo(raw) {
|
||||
const candidates = [
|
||||
raw?.jData?.sBindInfo,
|
||||
raw?.jData?.jData?.sBindInfo,
|
||||
raw?.sBindInfo,
|
||||
]
|
||||
|
||||
return candidates.find((item) => isPlainObject(item)) || null
|
||||
}
|
||||
|
||||
function normalizeAmsBindRoleInfo(bindInfo) {
|
||||
const source = isPlainObject(bindInfo) ? bindInfo : {}
|
||||
|
||||
return {
|
||||
name: String(source.sRoleName || source.roleName || source.name || '').trim(),
|
||||
rid: String(source.sRoleId || source.roleId || source.rid || '').trim(),
|
||||
rawInfo: isPlainObject(bindInfo) ? bindInfo : null,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHeaderMap(headers) {
|
||||
if (!headers || typeof headers !== 'object') {
|
||||
return {}
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(headers)
|
||||
.map(([key, value]) => [String(key || '').trim().toLowerCase(), String(value || '').trim()])
|
||||
.filter(([key, value]) => key && value),
|
||||
)
|
||||
}
|
||||
|
||||
function isPlainObject(value) {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user