Files
estel_docs/server/api/wechat/wx-config.get.ts
2025-08-11 13:53:30 +08:00

183 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { defineEventHandler, getQuery, setResponseHeader, createError } from 'h3'
import { createHash, randomBytes } from 'node:crypto'
type AccessTokenResponse = {
access_token: string
expires_in: number
errcode?: number
errmsg?: string
}
type JsapiTicketResponse = {
errcode: number
errmsg: string
ticket: string
expires_in: number
}
type WxConfigPayload = {
appId: string
timestamp: number
nonceStr: string
signature: string
}
/**
* GET /api/wechat/jssdk-config?url=<encoded_url>
*
* 仅新增本文件,不修改其他任何代码。
* 从环境变量读取:
* - WECHAT_APP_ID
* - WECHAT_APP_SECRET
*
* 按微信官方文档生成 JS-SDK 所需签名配置appId、timestamp、nonceStr、signature。
* 会在内存中缓存 access_token 与 jsapi_ticket缓存有效期比官方返回略短预留安全缓冲
*
* 参考文档:
* - JS-SDK 使用步骤(签名计算与 ticket 缓存https://developers.weixin.qq.com/doc/service/guide/h5/jssdk.html#JSSDK%E4%BD%BF%E7%94%A8%E6%AD%A5%E9%AA%A4
*/
const TOKEN_SAFETY_BUFFER_SECONDS = 300 // 5 分钟安全缓冲
let cachedAccessToken: { value: string, expiresAt: number } | null = null
let cachedJsapiTicket: { value: string, expiresAt: number } | null = null
function isExpired(cache: { expiresAt: number } | null): boolean {
if (!cache) return true
return Date.now() >= cache.expiresAt
}
async function fetchAccessToken(appId: string, appSecret: string): Promise<string> {
if (!isExpired(cachedAccessToken)) {
return cachedAccessToken!.value
}
const url = `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${encodeURIComponent(appId)}&secret=${encodeURIComponent(appSecret)}`
const response = await $fetch<AccessTokenResponse>(url, { method: 'GET' })
if (!response || !response.access_token) {
throw createError({
statusCode: 502,
statusMessage: `Failed to obtain access_token: ${response?.errcode ?? ''} ${response?.errmsg ?? ''}`.trim()
})
}
const expiresInSeconds = Math.max(0, (response.expires_in ?? 7200) - TOKEN_SAFETY_BUFFER_SECONDS)
cachedAccessToken = {
value: response.access_token,
expiresAt: Date.now() + expiresInSeconds * 1000
}
return response.access_token
}
async function fetchJsapiTicket(accessToken: string): Promise<string> {
if (!isExpired(cachedJsapiTicket)) {
return cachedJsapiTicket!.value
}
const url = `https://api.weixin.qq.com/cgi-bin/ticket/getticket?access_token=${encodeURIComponent(accessToken)}&type=jsapi`
const response = await $fetch<JsapiTicketResponse>(url, { method: 'GET' })
if (!response || response.errcode !== 0 || !response.ticket) {
throw createError({
statusCode: 502,
statusMessage: `Failed to obtain jsapi_ticket: ${response?.errcode ?? ''} ${response?.errmsg ?? ''}`.trim()
})
}
const expiresInSeconds = Math.max(0, (response.expires_in ?? 7200) - TOKEN_SAFETY_BUFFER_SECONDS)
cachedJsapiTicket = {
value: response.ticket,
expiresAt: Date.now() + expiresInSeconds * 1000
}
return response.ticket
}
function generateNonceStr(bytes: number = 16): string {
// 生成由 [a-z0-9] 组成的随机字符串
return randomBytes(bytes).toString('hex')
}
function generateTimestamp(): number {
return Math.floor(Date.now() / 1000)
}
function buildSignature(jsapiTicket: string, nonceStr: string, timestamp: number, url: string): string {
// 注意:参数名必须为全小写,且按字典序构造字符串
// 官方示例顺序jsapi_ticket, noncestr, timestamp, url
const rawString = `jsapi_ticket=${jsapiTicket}&noncestr=${nonceStr}&timestamp=${timestamp}&url=${url}`
return createHash('sha1').update(rawString).digest('hex')
}
export default defineEventHandler(async (event): Promise<WxConfigPayload> => {
// 避免代理缓存
setResponseHeader(event, 'Cache-Control', 'no-store')
const appId = process.env.WECHAT_APP_ID || ''
const appSecret = process.env.WECHAT_APP_SECRET || ''
if (!appId || !appSecret) {
throw createError({ statusCode: 500, statusMessage: 'Missing WECHAT_APP_ID or WECHAT_APP_SECRET in environment.' })
}
// 限制可签名的 URL 域名:从环境变量中获取站点基准地址,仅允许该主域或其子域
const siteBaseUrl = process.env.NUXT_PUBLIC_SITE_URL || process.env.SITE_BASE_URL || process.env.BASE_URL || 'lijue.net'
if (!siteBaseUrl) {
throw createError({ statusCode: 500, statusMessage: 'Missing site base URL in environment (NUXT_PUBLIC_SITE_URL/SITE_BASE_URL/BASE_URL).' })
}
let allowedHostname = ''
try {
allowedHostname = new URL(siteBaseUrl).hostname
} catch {
throw createError({ statusCode: 500, statusMessage: 'Invalid site base URL in environment. Expecting an absolute URL.' })
}
const query = getQuery(event)
let pageUrlRaw = typeof query.url === 'string' ? query.url : ''
// 若未传入 url则使用当前请求完整地址去除 hash通常建议前端传入当前页面 URL
if (!pageUrlRaw) {
const requestUrl = event.node.req.headers['x-forwarded-proto'] && event.node.req.headers['x-forwarded-host']
? `${event.node.req.headers['x-forwarded-proto']}://${event.node.req.headers['x-forwarded-host']}${event.node.req.url ?? ''}`
: new URL(event.node.req.url ?? '/', `http://${event.node.req.headers.host}`).toString()
pageUrlRaw = requestUrl
}
const pageUrlNoHash = pageUrlRaw.includes('#')
? pageUrlRaw.slice(0, pageUrlRaw.indexOf('#'))
: pageUrlRaw
const pageUrl = decodeURIComponent(pageUrlNoHash)
if (!/^https?:\/\//i.test(pageUrl)) {
throw createError({ statusCode: 400, statusMessage: 'Invalid url parameter. Expecting an absolute http/https URL.' })
}
// 校验传入 URL 的域名是否在允许范围(同主域或其子域)
let pageHostname = ''
try {
pageHostname = new URL(pageUrl).hostname
} catch {
throw createError({ statusCode: 400, statusMessage: 'Invalid url parameter. Expecting an absolute http/https URL.' })
}
const isSameHost = pageHostname === allowedHostname
const isSubdomain = pageHostname.endsWith('.' + allowedHostname)
if (!isSameHost && !isSubdomain) {
throw createError({ statusCode: 400, statusMessage: 'The url hostname is not allowed.' })
}
const accessToken = await fetchAccessToken(appId, appSecret)
const jsapiTicket = await fetchJsapiTicket(accessToken)
const timestamp = generateTimestamp()
const nonceStr = generateNonceStr(16)
const signature = buildSignature(jsapiTicket, nonceStr, timestamp, pageUrl)
return {
appId,
timestamp,
nonceStr,
signature
}
})