feat: 新增实验性微信卡片分享与自动部署能力

补充 Cloudflare Worker、R2 存储、微信 JS-SDK 签名与上传鉴权
增加自动部署 Skill 和配置引导文档
优化报告工具栏、微信卡片弹窗及窄屏响应式布局
补充 Worker 鉴权、卡片生成、过期清理与安全转义测试
This commit is contained in:
Wxw-Gu
2026-08-13 10:48:07 +08:00
parent d439b4b749
commit 4436d7c8ce
45 changed files with 3832 additions and 69 deletions
+26
View File
@@ -0,0 +1,26 @@
# WechatExplorer share-card worker
Cloudflare Worker + private R2 service for temporary WeChat report cards.
This is an experimental, self-hosted feature. See the complete Chinese deployment guide:
- `../../docs/deployment/experimental-wechat-share-card.md`
Required encrypted secrets:
- `WECHAT_APP_ID`
- `WECHAT_APP_SECRET`
- `UPLOAD_TOKEN` (random 32+ character token also saved in WechatExplorer's secure settings)
Create the private bucket, set secrets, and deploy:
```bash
npx wrangler r2 bucket create wechatexplorer-share-reports
npx wrangler secret put WECHAT_APP_ID
npx wrangler secret put WECHAT_APP_SECRET
npx wrangler secret put UPLOAD_TOKEN
npx wrangler deploy
```
Keep the R2 public development URL disabled. All reads go through the Worker and expire with
the card.
+13
View File
@@ -0,0 +1,13 @@
{
"name": "wechatexplorer-share-card-worker",
"private": true,
"type": "module",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"check": "node --check src/index.js && node --test test/index.test.js"
},
"devDependencies": {
"wrangler": "^4.28.1"
}
}
+318
View File
@@ -0,0 +1,318 @@
const encoder = new TextEncoder()
const DAY_MS = 86_400_000
// Add the TXT filename and content supplied by the WeChat test-account page when
// domain verification is required. Never commit a real verification value.
const WECHAT_DOMAIN_VERIFICATION = new Map()
const json = (value, init = {}) =>
new Response(JSON.stringify(value), {
...init,
headers: { 'content-type': 'application/json; charset=utf-8', ...(init.headers || {}) }
})
const escapeHtml = (value) =>
String(value ?? '')
.replaceAll('&', '&')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;')
const safeJson = (value) => JSON.stringify(value).replaceAll('<', '\\u003c')
const cardKey = (id) => `cards/${id}/card.json`
const imageKey = (id) => `cards/${id}/report.png`
const thumbnailKey = (id) => `cards/${id}/thumbnail.jpg`
const storageUnavailable = () =>
json(
{
error: '图片存储服务尚未启用,请在 Cloudflare 控制台启用 R2 后重新生成分享卡片'
},
{ status: 503 }
)
const readCard = async (env, id) => {
if (!/^[0-9a-f-]{36}$/i.test(id)) return null
const object = await env.REPORTS.get(cardKey(id))
if (!object) return null
const card = JSON.parse(await object.text())
if (Date.parse(card.expiresAt) <= Date.now()) {
await deleteCard(env, id)
return null
}
return card
}
const deleteCard = async (env, id) => {
await env.REPORTS.delete([cardKey(id), imageKey(id), thumbnailKey(id)])
}
const bearerAuthorized = (request, env) => {
const value = request.headers.get('authorization') || ''
return Boolean(env.UPLOAD_TOKEN) && value === `Bearer ${env.UPLOAD_TOKEN}`
}
const publicOrigin = (request, env) =>
String(env.PUBLIC_ORIGIN || new URL(request.url).origin).replace(/\/+$/, '')
const createCard = async (request, env) => {
if (!bearerAuthorized(request, env)) return json({ error: '未授权' }, { status: 401 })
const body = await request.json().catch(() => null)
if (!body) return json({ error: '请求体无效' }, { status: 400 })
const title = String(body.title || '')
.trim()
.slice(0, 64)
const description = String(body.description || '')
.trim()
.slice(0, 120)
if (!title || !body.imageBase64 || !body.thumbnailBase64) {
return json({ error: '缺少标题或图片' }, { status: 400 })
}
const image = Uint8Array.from(atob(body.imageBase64), (char) => char.charCodeAt(0))
const thumbnail = Uint8Array.from(atob(body.thumbnailBase64), (char) => char.charCodeAt(0))
if (image.byteLength > 25 * 1024 * 1024 || thumbnail.byteLength > 2 * 1024 * 1024) {
return json({ error: '图片超过大小限制' }, { status: 413 })
}
const id = crypto.randomUUID()
const days = Math.max(1, Math.min(30, Number(body.expiresInDays || env.DEFAULT_EXPIRY_DAYS || 7)))
const createdAt = new Date().toISOString()
const expiresAt = new Date(Date.now() + days * DAY_MS).toISOString()
const card = { id, title, description, createdAt, expiresAt }
await Promise.all([
env.REPORTS.put(cardKey(id), JSON.stringify(card), {
httpMetadata: { contentType: 'application/json; charset=utf-8' }
}),
env.REPORTS.put(imageKey(id), image, {
httpMetadata: { contentType: 'image/png', cacheControl: 'private, max-age=300' }
}),
env.REPORTS.put(thumbnailKey(id), thumbnail, {
httpMetadata: { contentType: 'image/jpeg', cacheControl: 'public, max-age=300' }
})
])
const origin = publicOrigin(request, env)
return json({
cardId: id,
shareUrl: `${origin}/s/${id}`,
viewUrl: `${origin}/v/${id}`,
expiresAt
})
}
const serveAsset = async (env, id, kind) => {
const card = await readCard(env, id)
if (!card) return new Response('Not found', { status: 404 })
const object = await env.REPORTS.get(kind === 'thumbnail' ? thumbnailKey(id) : imageKey(id))
if (!object) return new Response('Not found', { status: 404 })
const headers = new Headers()
object.writeHttpMetadata(headers)
headers.set('x-content-type-options', 'nosniff')
headers.set('cache-control', kind === 'thumbnail' ? 'public, max-age=300' : 'private, max-age=60')
return new Response(object.body, { headers })
}
const sharePage = (request, env, card) => {
const origin = publicOrigin(request, env)
const viewUrl = `${origin}/v/${card.id}`
const cardLinkUrl = `${origin}/l/${card.id}`
const imageUrl = `${origin}/a/${card.id}/thumbnail`
const share = { title: card.title, desc: card.description, link: cardLinkUrl, imgUrl: imageUrl }
return new Response(
`<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta property="og:title" content="${escapeHtml(card.title)}">
<meta property="og:description" content="${escapeHtml(card.description)}">
<meta property="og:image" content="${escapeHtml(imageUrl)}">
<title>${escapeHtml(card.title)}</title>
<style>
*{box-sizing:border-box}body{margin:0;background:#f3f6f5;color:#17201d;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
main{min-height:100vh;padding:48px 24px;display:flex;align-items:center;justify-content:center}
.card{width:min(100%,440px);background:#fff;border-radius:24px;padding:30px;box-shadow:0 18px 60px rgba(23,32,29,.10);text-align:center}
.arrow{font-size:52px;color:#16a66a;transform:rotate(-20deg);margin:-10px 0 12px}
h1{font-size:23px;margin:0 0 12px}.desc{color:#61706a;line-height:1.7;margin:0 0 28px}
.hint{background:#ecf8f2;border:1px solid #cdebdc;border-radius:16px;padding:18px;line-height:1.7}
.open{display:inline-block;margin-top:22px;color:#08794c;text-decoration:none;font-weight:650}
#status{font-size:13px;color:#7f8d87;margin-top:18px}
</style>
</head>
<body><main><section class="card">
<div class="arrow">↗</div>
<h1>点击右上角 ··· 分享</h1>
<p class="desc">${escapeHtml(card.description)}</p>
<div class="hint">发送给好友或群聊后,将显示为标题、描述和缩略图组成的微信卡片。</div>
<a class="open" href="${escapeHtml(viewUrl)}">先查看完整日报</a>
<p id="status">正在准备微信分享信息…</p>
</section></main>
<script src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>
<script>
const share=${safeJson(share)};
const status=document.getElementById('status');
fetch('/api/wx-signature?url='+encodeURIComponent(location.href.split('#')[0]))
.then(r=>r.json().then(data=>({ok:r.ok,data})))
.then(({ok,data})=>{
if(!ok) throw new Error(data.error||'签名失败');
wx.config({...data,debug:false,jsApiList:['updateAppMessageShareData','updateTimelineShareData']});
wx.ready(()=>{
wx.updateAppMessageShareData({...share,success:()=>status.textContent='分享卡片已准备好'});
wx.updateTimelineShareData({title:share.title,link:share.link,imgUrl:share.imgUrl});
status.textContent='分享卡片已准备好';
});
wx.error(err=>{status.textContent='微信分享配置失败:'+(err.errMsg||'未知错误')});
})
.catch(err=>{status.textContent='微信分享配置失败:'+err.message});
</script></body></html>`,
{
headers: {
'content-type': 'text/html; charset=utf-8',
'cache-control': 'no-store',
'content-security-policy':
"default-src 'self'; script-src 'self' 'unsafe-inline' https://res.wx.qq.com; img-src 'self' data:; style-src 'self' 'unsafe-inline'; connect-src 'self'"
}
}
)
}
const viewPage = (env, card) => {
const origin = String(env.PUBLIC_ORIGIN).replace(/\/+$/, '')
return new Response(
`<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(card.title)}</title><style>*{box-sizing:border-box}body{margin:0;background:#eef2f0;color:#17201d;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}header{padding:20px;background:#fff;position:sticky;top:0;box-shadow:0 1px 8px #0001}h1{font-size:18px;margin:0 0 6px}p{margin:0;color:#68746f;font-size:13px}.image{display:block;width:min(100%,900px);height:auto;margin:20px auto;background:#fff}</style></head><body><header><h1>${escapeHtml(card.title)}</h1><p>${escapeHtml(card.description)} · 有效期至 ${escapeHtml(card.expiresAt.slice(0, 10))}</p></header><img class="image" src="${origin}/a/${card.id}/report" alt="${escapeHtml(card.title)}"></body></html>`,
{
headers: {
'content-type': 'text/html; charset=utf-8',
'cache-control': 'no-store',
'content-security-policy': "default-src 'self'; img-src 'self'; style-src 'unsafe-inline'"
}
}
)
}
const cachedWechatValue = async (cacheKey, ttl, loader) => {
const cache = caches.default
const request = new Request(`https://wechat-cache.invalid/${cacheKey}`)
const cached = await cache.match(request)
if (cached) return cached.json()
const value = await loader()
await cache.put(request, json(value, { headers: { 'cache-control': `public, max-age=${ttl}` } }))
return value
}
const getAccessToken = (env) =>
cachedWechatValue(`access-token/${env.WECHAT_APP_ID}`, 6900, async () => {
const url = new URL('https://api.weixin.qq.com/cgi-bin/token')
url.searchParams.set('grant_type', 'client_credential')
url.searchParams.set('appid', env.WECHAT_APP_ID)
url.searchParams.set('secret', env.WECHAT_APP_SECRET)
const data = await fetch(url).then((response) => response.json())
if (!data.access_token) throw new Error(data.errmsg || '无法获取 access_token')
return { value: data.access_token }
})
const getTicket = async (env) => {
const token = await getAccessToken(env)
return cachedWechatValue(`jsapi-ticket/${env.WECHAT_APP_ID}`, 6900, async () => {
const url = new URL('https://api.weixin.qq.com/cgi-bin/ticket/getticket')
url.searchParams.set('access_token', token.value)
url.searchParams.set('type', 'jsapi')
const data = await fetch(url).then((response) => response.json())
if (!data.ticket) throw new Error(data.errmsg || '无法获取 jsapi_ticket')
return { value: data.ticket }
})
}
const sha1 = async (value) => {
const digest = await crypto.subtle.digest('SHA-1', encoder.encode(value))
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('')
}
const signature = async (request, env) => {
if (!env.WECHAT_APP_ID || !env.WECHAT_APP_SECRET) {
return json({ error: '微信 JS-SDK 尚未配置' }, { status: 503 })
}
const pageUrl = new URL(request.url).searchParams.get('url')
if (!pageUrl) return json({ error: '缺少签名 URL' }, { status: 400 })
const parsed = new URL(pageUrl)
if (parsed.origin !== publicOrigin(request, env)) {
return json({ error: '只能签名当前分享域名' }, { status: 400 })
}
const ticket = await getTicket(env)
const nonceStr = crypto.randomUUID().replaceAll('-', '')
const timestamp = Math.floor(Date.now() / 1000)
const source = `jsapi_ticket=${ticket.value}&noncestr=${nonceStr}&timestamp=${timestamp}&url=${pageUrl}`
return json({
appId: env.WECHAT_APP_ID,
timestamp,
nonceStr,
signature: await sha1(source)
})
}
const router = async (request, env) => {
const url = new URL(request.url)
const verificationContent = WECHAT_DOMAIN_VERIFICATION.get(url.pathname.slice(1))
if (request.method === 'GET' && verificationContent) {
return new Response(verificationContent, {
headers: {
'content-type': 'text/plain; charset=utf-8',
'cache-control': 'public, max-age=300',
'x-content-type-options': 'nosniff'
}
})
}
if (request.method === 'GET' && url.pathname === '/health') {
return json({
ok: true,
service: 'wechatexplorer-share-card',
storage: env.REPORTS ? 'ready' : 'unavailable'
})
}
if (!env.REPORTS && (url.pathname === '/api/cards' || /^\/(s|l|v|a)\//i.test(url.pathname))) {
return storageUnavailable()
}
if (request.method === 'POST' && url.pathname === '/api/cards') return createCard(request, env)
if (request.method === 'GET' && url.pathname === '/api/wx-signature') {
try {
return await signature(request, env)
} catch (error) {
return json(
{ error: error instanceof Error ? error.message : String(error) },
{ status: 502 }
)
}
}
const match = url.pathname.match(/^\/(s|l|v|a)\/([0-9a-f-]{36})(?:\/(report|thumbnail))?$/i)
if (!match) return new Response('Not found', { status: 404 })
const [, route, id, asset] = match
if (route === 'a') return serveAsset(env, id, asset)
const card = await readCard(env, id)
if (!card) return new Response('卡片不存在或已过期', { status: 404 })
if (route === 'l') {
return Response.redirect(`${publicOrigin(request, env)}/v/${card.id}`, 302)
}
return route === 's' ? sharePage(request, env, card) : viewPage(env, card)
}
const cleanup = async (env) => {
let cursor
do {
const listed = await env.REPORTS.list({ prefix: 'cards/', cursor, include: ['httpMetadata'] })
const metadataObjects = listed.objects.filter((object) => object.key.endsWith('/card.json'))
for (const item of metadataObjects) {
const object = await env.REPORTS.get(item.key)
if (!object) continue
const card = JSON.parse(await object.text())
if (Date.parse(card.expiresAt) <= Date.now()) await deleteCard(env, card.id)
}
cursor = listed.truncated ? listed.cursor : undefined
} while (cursor)
}
export default {
fetch: (request, env) => router(request, env),
scheduled: (_controller, env, ctx) => ctx.waitUntil(cleanup(env))
}
export { escapeHtml, sha1 }
@@ -0,0 +1,137 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import worker, { escapeHtml, sha1 } from '../src/index.js'
class MemoryR2Object {
constructor(value, metadata = {}) {
this.value = value
this.metadata = metadata
}
async text() {
return new TextDecoder().decode(this.value)
}
get body() {
return this.value
}
writeHttpMetadata(headers) {
if (this.metadata.contentType) headers.set('content-type', this.metadata.contentType)
if (this.metadata.cacheControl) headers.set('cache-control', this.metadata.cacheControl)
}
}
class MemoryR2 {
objects = new Map()
async put(key, value, options = {}) {
const bytes =
typeof value === 'string' ? new TextEncoder().encode(value) : new Uint8Array(value)
this.objects.set(key, new MemoryR2Object(bytes, options.httpMetadata))
}
async get(key) {
return this.objects.get(key) || null
}
async delete(keys) {
for (const key of Array.isArray(keys) ? keys : [keys]) this.objects.delete(key)
}
}
const env = () => ({
REPORTS: new MemoryR2(),
UPLOAD_TOKEN: 'test-upload-token-that-is-long-enough',
PUBLIC_ORIGIN: 'https://share.example.com',
DEFAULT_EXPIRY_DAYS: '7'
})
test('requires the upload bearer token', async () => {
const response = await worker.fetch(
new Request('https://share.example.com/api/cards', { method: 'POST', body: '{}' }),
env()
)
assert.equal(response.status, 401)
})
test('returns a controlled error when R2 is not configured', async () => {
const response = await worker.fetch(
new Request('https://share.example/s/d9069d5a-d1a0-44fc-a983-2602c3f1cb94'),
{ PUBLIC_ORIGIN: 'https://share.example' }
)
assert.equal(response.status, 503)
assert.match(await response.text(), /图片存储服务尚未启用/)
})
test('creates an expiring card and serves only its random assets', async () => {
const testEnv = env()
const response = await worker.fetch(
new Request('https://share.example.com/api/cards', {
method: 'POST',
headers: {
authorization: `Bearer ${testEnv.UPLOAD_TOKEN}`,
'content-type': 'application/json'
},
body: JSON.stringify({
title: '技术交流群日报',
description: '今日群聊总结',
imageBase64: Buffer.from('png-data').toString('base64'),
thumbnailBase64: Buffer.from('jpeg-data').toString('base64')
})
}),
testEnv
)
assert.equal(response.status, 200)
const card = await response.json()
assert.match(card.cardId, /^[0-9a-f-]{36}$/)
assert.equal(card.shareUrl, `https://share.example.com/s/${card.cardId}`)
const page = await worker.fetch(new Request(card.shareUrl), testEnv)
assert.equal(page.status, 200)
const pageHtml = await page.text()
assert.match(pageHtml, /updateAppMessageShareData/)
assert.match(pageHtml, new RegExp(`/l/${card.cardId}`))
const link = await worker.fetch(
new Request(`https://share.example.com/l/${card.cardId}`),
testEnv
)
assert.equal(link.status, 302)
assert.equal(link.headers.get('location'), `https://share.example.com/v/${card.cardId}`)
const asset = await worker.fetch(
new Request(`https://share.example.com/a/${card.cardId}/thumbnail`),
testEnv
)
assert.equal(asset.status, 200)
assert.equal(await asset.text(), 'jpeg-data')
})
test('escapes untrusted card metadata and produces the expected SHA-1', async () => {
assert.equal(
escapeHtml(`<img src=x onerror="alert('x')">&`),
'&lt;img src=x onerror=&quot;alert(&#39;x&#39;)&quot;&gt;&amp;'
)
assert.equal(await sha1('abc'), 'a9993e364706816aba3e25717850c26c9cd0d89d')
})
test('removes expired cards when they are requested', async () => {
const testEnv = env()
const id = '11111111-1111-4111-8111-111111111111'
await testEnv.REPORTS.put(
`cards/${id}/card.json`,
JSON.stringify({
id,
title: 'expired',
description: '',
expiresAt: new Date(Date.now() - 1000).toISOString()
})
)
await testEnv.REPORTS.put(`cards/${id}/report.png`, 'image')
await testEnv.REPORTS.put(`cards/${id}/thumbnail.jpg`, 'thumb')
const response = await worker.fetch(new Request(`https://share.example.com/s/${id}`), testEnv)
assert.equal(response.status, 404)
assert.equal(testEnv.REPORTS.objects.size, 0)
})
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "wechatexplorer-share-card",
"main": "src/index.js",
"compatibility_date": "2026-07-23",
"routes": [
{
"pattern": "share.example.com",
"custom_domain": true
}
],
"r2_buckets": [
{
"binding": "REPORTS",
"bucket_name": "wechatexplorer-share-reports"
}
],
"triggers": {
"crons": ["17 3 * * *"]
},
"vars": {
"PUBLIC_ORIGIN": "https://share.example.com",
"DEFAULT_EXPIRY_DAYS": "7"
}
}
@@ -0,0 +1,16 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "wechatexplorer-share-card",
"main": "src/index.js",
"compatibility_date": "2026-07-23",
"routes": [
{
"pattern": "share.example.com",
"custom_domain": true
}
],
"vars": {
"PUBLIC_ORIGIN": "https://share.example.com",
"DEFAULT_EXPIRY_DAYS": "7"
}
}