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('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", ''') 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( ` ${escapeHtml(card.title)}
↗

点击右上角 ··· 分享

${escapeHtml(card.description)}

发送给好友或群聊后,将显示为标题、描述和缩略图组成的微信卡片。
先查看完整日报

正在准备微信分享信息…

`, { 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( `${escapeHtml(card.title)}

${escapeHtml(card.title)}

${escapeHtml(card.description)} · 有效期至 ${escapeHtml(card.expiresAt.slice(0, 10))}

${escapeHtml(card.title)}`, { 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}×tamp=${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 }