diff --git a/.env.example b/.env.example index baeded7..1568aab 100644 --- a/.env.example +++ b/.env.example @@ -26,3 +26,11 @@ VITE_IMAGE_AES_KEY= # Electron E2E test window close delay in milliseconds. # Local default: 2000 (2 seconds). Set to 0 for immediate close. WXE_E2E_CLOSE_DELAY_MS=2000 + +# Experimental self-hosted WeChat share-card service +# Copy these placeholders to .env. Never commit real AppSecret or UPLOAD_TOKEN values. +WECHAT_SHARE_DOMAIN=share.example.com +WECHAT_SHARE_APP_ID= +WECHAT_SHARE_APP_SECRET= +# Leave empty to let docs/skill/setup-wechat-share-card/scripts/setup.sh generate one. +WECHAT_SHARE_UPLOAD_TOKEN= diff --git a/.gitignore b/.gitignore index 5ee19a7..90a65a3 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,8 @@ test-results/ resources/connectors/wechat/ .omc .codex/ +services/share-card-worker/.wrangler/ +services/share-card-worker/wrangler.local.jsonc docs/design/ docs/ui-redesign-plan.md docs/ui-redesign-spec.md diff --git a/docs/README.md b/docs/README.md index 07b74d7..ae434cb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,6 +12,8 @@ TraceMemo 的文档按“你想完成什么”组织,而不是按源码模块 - [建立本地知识库](./user-guide/knowledge.md) - [生成群聊日报和总结](./user-guide/report.md) +- [实验性:自托管微信分享卡片](./deployment/experimental-wechat-share-card.md) +- [交给 Agent 自动部署微信分享卡片](./skill/setup-wechat-share-card/SKILL.md) - [语音转文字](./user-guide/voice.md) - [导出聊天档案](./user-guide/export.md) - [防撤回](./user-guide/recall-protection.md) diff --git a/docs/deployment/experimental-wechat-share-card.md b/docs/deployment/experimental-wechat-share-card.md new file mode 100644 index 0000000..92838de --- /dev/null +++ b/docs/deployment/experimental-wechat-share-card.md @@ -0,0 +1,447 @@ +# 实验性功能:自托管微信分享卡片 + +![微信卡片分享效果示例](../../public/微信卡片分享.png) + +> **实验性功能** +> 该能力需要用户自行准备 Cloudflare、域名和微信公众平台测试号,目前不属于开箱即用的稳定功能。Cloudflare、微信 JS-SDK、测试号权限或微信客户端行为变化,都可能导致分享卡片失效。 + +## 新手推荐:直接交给 Agent + +如果你不熟悉 Cloudflare、Wrangler 或命令行,不需要手动照着整篇文档操作。把下面这个 Skill 文件夹交给 Codex、Claude Code 或其他能够操作项目终端的编程 Agent: + +```text +docs/skill/setup-wechat-share-card/ +``` + +然后对 Agent 说: + +```text +请使用 setup-wechat-share-card Skill,帮我部署 TraceMemo 的实验性微信分享卡片服务。尽量自动完成,只在缺少必要信息时一次性问我。 +``` + +Agent 会自动: + +- 检查 Node.js、pnpm 和 Wrangler; +- 必要时临时下载 Wrangler; +- 打开 Cloudflare 登录并执行 `whoami`; +- 自动生成 `UPLOAD_TOKEN`; +- 创建或复用私有 R2 Bucket; +- 写入 Worker Secret; +- 根据你的域名生成本机 Worker 配置; +- 部署 Worker并执行健康检查和微信签名检查; +- 把上传密钥复制到剪贴板,供你粘贴到 TraceMemo。 + +Agent 无法替你创建微信测试号或决定使用哪个域名,因此通常只需要你提供: + +1. 你准备使用的分享域名,例如 `share.example.com`; +2. 微信测试号页面中的 AppID; +3. 微信测试号页面中的 AppSecret; +4. 浏览器弹出 Cloudflare OAuth 页面时完成一次登录授权。 + +真实配置保存在被 Git 忽略的本机 `.env` 中,不会写入 `.env.example`。不要把 `.env` 发给别人或提交到仓库。 + +TraceMemo 可以把已经生成的群聊日报长图发布为一个临时网页,并在微信中分享成带有标题、描述和缩略图的卡片。 + +TraceMemo **不提供公共卡片服务器**。使用该功能前,需要按照本文部署一套属于你自己的卡片服务。日报图片将上传到你自己的 Cloudflare R2,而不是上传到 TraceMemo 作者的服务器。 + +## 这个功能解决什么问题 + +直接把日报 PNG 发到微信,只会显示为一张普通图片。微信卡片还需要: + +- 一个域名 (未能备案的话 在微信里点击多次 可能会被微信内置窗口提示需要备案); +- 卡片标题和描述; +- 一张微信可以读取的缩略图; +- 微信 JS-SDK 签名; +- 一个临时保存日报图片的位置。 + +本项目提供的 Cloudflare Worker 负责这些工作。桌面端上传日报后,会得到一个分享链接和二维码。用微信扫码打开链接,再点击右上角菜单分享,即可生成微信卡片。 + +## 数据会经过哪里 + +```mermaid +flowchart LR + A[TraceMemo 本机日报 PNG] -->|带 UPLOAD_TOKEN 上传| B[你的 Cloudflare Worker] + B --> C[你的私有 R2 Bucket] + B -->|AppID + AppSecret| D[微信公众平台接口] + D -->|access_token 与 jsapi_ticket| B + B --> E[临时分享网页] + E --> F[微信 JS-SDK] + F --> G[微信好友或群聊卡片] +``` + +与 TraceMemo 的本地浏览能力不同,启用分享卡片后,当前日报长图、缩略图、卡片标题和描述会离开本机,上传到你控制的 Cloudflare 账号。 + +## 你需要准备什么 + +| 项目 | 用途 | 从哪里获得 | +| ------------------------ | -------------------------------------------- | ---------------------------------------- | +| Cloudflare 账号 | 运行 Worker 和保存 R2 图片 | 自行注册 Cloudflare | +| 托管在 Cloudflare 的域名 | 提供 HTTPS 分享地址 | 使用自己的域名,例如 `share.example.com` | +| R2 Bucket | 临时保存日报和缩略图 | 使用 Wrangler 创建 | +| `UPLOAD_TOKEN` | 阻止陌生人调用你的上传接口 | **由你自己随机生成** | +| 微信测试号 AppID | 标识调用 JS-SDK 的微信应用 | 微信公众平台接口测试号页面 | +| 微信测试号 AppSecret | Worker 获取微信接口凭据 | 微信公众平台接口测试号页面 | +| JS 接口安全域名 | 告诉微信哪些网页可以使用该 AppID 调用 JS-SDK | 在微信测试号页面填写你的分享域名 | + +微信公众平台接口测试号入口: + + + +微信 JS-SDK 官方文档: + + + +## 理解三个重要配置 + +### `UPLOAD_TOKEN` 从哪里来 + +`UPLOAD_TOKEN` **不是从 Cloudflare 或微信后台领取的**,它是卡片服务部署者自己生成的一段随机密码。 + +它用于保护 Worker 的上传接口:TraceMemo 上传日报时,会发送: + +```http +Authorization: Bearer +``` + +Worker 只有在密钥完全一致时才接受上传。没有它,任何知道接口地址的人都可能向你的 R2 上传文件并消耗资源。 + +在 macOS 或 Linux 中生成一枚 64 位十六进制随机密钥: + +```bash +openssl rand -hex 32 +``` + +示例输出只用于说明格式,不要直接使用: + +```text +8a4d...一共 64 个十六进制字符...72ef +``` + +生成后,同一个值需要配置到两个地方: + +1. Cloudflare Worker Secret `UPLOAD_TOKEN`; +2. TraceMemo“生成微信卡片”弹窗中的“上传密钥”。 + +如果两边不一致,卡片服务会返回 HTTP 401 或“未授权”。 + +TraceMemo 会使用 Electron `safeStorage` 将服务地址和上传密钥加密保存在本机。不要把密钥提交到 Git,也不要写入 `wrangler.jsonc`。 + +### AppID 和 AppSecret 从哪里来 + +打开[微信公众平台接口测试号](https://mp.weixin.qq.com/debug/cgi-bin/sandboxinfo?action=showinfo&t=sandbox/index),使用微信扫码登录。 + +页面上方会显示: + +- `appID`; +- `appsecret`。 + +将它们分别保存为 Worker Secret: + +```text +WECHAT_APP_ID +WECHAT_APP_SECRET +``` + +它们的作用不同: + +- AppID 用于标识这个微信测试应用; +- AppSecret 是高敏感凭据,Worker 用它向微信服务器获取 `access_token`; +- Worker 再使用 `access_token` 获取 `jsapi_ticket`; +- 最后使用 `jsapi_ticket`、当前网页 URL、时间戳和随机串生成 JS-SDK 签名。 + +AppSecret 只能保存在 Worker Secret 中。不要把它填写到 TraceMemo 的“上传密钥”输入框,不要发送给前端,也不要提交到仓库。怀疑泄露时,应立即在微信后台重置并更新 Worker Secret。 + +### JS 接口安全域名是干什么的 + +JS 接口安全域名是微信对网页来源的白名单。 + +假设你的分享服务地址是: + +```text +https://share.example.com +``` + +那么测试号页面中的“JS 接口安全域名”应填写: + +```text +share.example.com +``` + +填写时: + +- 不带 `https://`; +- 不带 `/s/xxx` 等路径; +- 不要填写 Cloudflare Worker 名称; +- 必须与用户实际打开分享页时的域名一致。 + +它不是用来解析 DNS 的。域名仍然需要先在 Cloudflare 中正确绑定到 Worker。安全域名的作用是告诉微信:允许这个域名下的网页使用当前 AppID 请求 JS-SDK 能力。 + +如果没有配置、填错域名,或签名 URL 与实际页面 URL 不一致,通常会出现 `invalid signature`、`config:fail` 或分享信息没有生效。 + +微信可能要求下载一个 TXT 验证文件,并确保它可以通过下面的地址访问: + +```text +https://share.example.com/微信提供的文件名.txt +``` + +项目 Worker 已包含根路径验证文件的实现方式。你需要把自己的文件名和内容加入 `services/share-card-worker/src/index.js` 中的 `WECHAT_DOMAIN_VERIFICATION`,然后重新部署。 + +## 自托管部署步骤 + +以下命令均在项目根目录执行。 + +### 1. 登录 Cloudflare + +项目建议使用本地 Wrangler: + +```bash +pnpm exec wrangler login +pnpm exec wrangler whoami +``` + +如果本地版本的 OAuth 登录出现 `invalid_scope` 等问题,可临时使用更新版本: + +```bash +pnpm dlx wrangler@latest login +pnpm dlx wrangler@latest whoami +``` + +登录注意事项: + +- 让 Wrangler 自动打开浏览器最稳妥; +- 不要复用以前生成的 OAuth 链接; +- 不要修改链接中的 `state`、`code_challenge` 或回调地址; +- 不建议使用无痕窗口或跨浏览器复制链接; +- 默认回调使用 `localhost:8976`,端口被占用时先结束旧的 Wrangler 登录进程; +- 浏览器提示授权成功后,仍应通过 `whoami` 核对账号。 + +### 2. 修改 Worker 配置 + +打开: + +```text +services/share-card-worker/wrangler.jsonc +``` + +至少修改下面两个位置: + +```jsonc +{ + "routes": [ + { + "pattern": "share.example.com", + "custom_domain": true + } + ], + "vars": { + "PUBLIC_ORIGIN": "https://share.example.com", + "DEFAULT_EXPIRY_DAYS": "7" + } +} +``` + +`routes[].pattern` 是 Worker 自定义域名,`PUBLIC_ORIGIN` 是生成分享链接和校验签名来源时使用的完整 HTTPS 地址,两者必须一致。 + +不要直接照抄仓库维护者的域名。请替换为你自己 Cloudflare 账号中的域名或子域名。 + +### 3. 创建私有 R2 Bucket + +默认配置使用 Bucket 名称: + +```text +wechatexplorer-share-reports +``` + +创建: + +```bash +pnpm exec wrangler r2 bucket create wechatexplorer-share-reports \ + --config services/share-card-worker/wrangler.jsonc +``` + +Worker 中的绑定名称是 `REPORTS`。R2 会保存: + +```text +cards//card.json +cards//report.png +cards//thumbnail.jpg +``` + +- `card.json`:标题、描述、创建时间和过期时间; +- `report.png`:完整日报长图; +- `thumbnail.jpg`:微信卡片缩略图。 + +请保持 R2 Bucket 私有,不要启用公开 `r2.dev` 开发 URL。图片应统一通过 Worker 的随机卡片 URL 读取。 + +### 4. 生成并配置 `UPLOAD_TOKEN` + +```bash +openssl rand -hex 32 +``` + +复制生成结果,然后执行: + +```bash +pnpm exec wrangler secret put UPLOAD_TOKEN \ + --config services/share-card-worker/wrangler.jsonc +``` + +Wrangler 提示输入时粘贴密钥。终端不会正常显示 Secret 内容。 + +### 5. 配置微信 AppID 和 AppSecret + +从[微信公众平台接口测试号](https://mp.weixin.qq.com/debug/cgi-bin/sandboxinfo?action=showinfo&t=sandbox/index)复制 AppID: + +```bash +pnpm exec wrangler secret put WECHAT_APP_ID \ + --config services/share-card-worker/wrangler.jsonc +``` + +再复制 AppSecret: + +```bash +pnpm exec wrangler secret put WECHAT_APP_SECRET \ + --config services/share-card-worker/wrangler.jsonc +``` + +Secret 不会出现在 `wrangler.jsonc` 中。如果你更换 Cloudflare 账号或重新创建 Worker,需要重新配置全部三个 Secret。 + +### 6. 配置微信测试号 + +在测试号页面完成: + +1. 使用测试微信关注该测试号; +2. 将 `share.example.com` 填入“JS 接口安全域名”; +3. 按页面提示完成 TXT 文件域名验证; +4. 确认 AppID/AppSecret 与刚才写入 Worker 的值来自同一个测试号。 + +测试号只适合开发和验证。正式公众号的接口权限、认证要求和后台菜单可能不同,请以微信公众平台实际规则为准。 + +### 7. 部署 Worker + +```bash +pnpm exec wrangler deploy \ + --config services/share-card-worker/wrangler.jsonc +``` + +Cloudflare Custom Domain 要求域名已经位于同一 Cloudflare 账号中。如果该子域名已经存在 A、AAAA 或 CNAME 记录,绑定可能失败。删除冲突记录,或者换一个未使用的子域名,例如 `share2.example.com`。 + +更新 Secret 后,如果线上仍提示旧配置,可再执行一次完整部署。 + +## 在 TraceMemo 中配置 + +生成一份日报后,点击“生成微信卡片(实验性)”。首次使用需要填写: + +```text +服务地址:https://share.example.com +上传密钥:你自己通过 openssl rand -hex 32 生成的 UPLOAD_TOKEN +``` + +这里的“上传密钥”绝对不是微信 AppSecret。 + +配置保存后,TraceMemo 会上传当前日报和缩略图,返回二维码。使用已经关注测试号的微信扫码,打开页面后再通过右上角菜单分享。 + +## 验证部署 + +### 健康检查 + +```bash +curl -fsS https://share.example.com/health +``` + +正常结果类似: + +```json +{ "ok": true, "service": "wechatexplorer-share-card", "storage": "ready" } +``` + +### JS-SDK 签名检查 + +```bash +curl -fsS \ + 'https://share.example.com/api/wx-signature?url=https%3A%2F%2Fshare.example.com%2Fhealth' +``` + +正常结果应包含: + +```text +appId +timestamp +nonceStr +signature +``` + +响应中不应包含 AppSecret、`access_token` 或 `jsapi_ticket`。 + +## 常见问题 + +### HTTP 401 / 未授权 + +TraceMemo 中保存的上传密钥与 Worker 的 `UPLOAD_TOKEN` 不一致。重新生成或重新配置时,必须同步更新两边。 + +### “微信 JS-SDK 尚未配置” + +Worker 缺少 `WECHAT_APP_ID` 或 `WECHAT_APP_SECRET`。执行两个 `secret put`,再重新部署。 + +### `invalid signature` 或分享信息不生效 + +依次检查: + +- `PUBLIC_ORIGIN` 是否与浏览器实际访问的 origin 完全一致; +- JS 接口安全域名是否只填写了域名; +- AppID/AppSecret 是否属于同一个测试号; +- AppSecret 是否已被重置但 Worker 仍保存旧值; +- 分享页面是否经过了改变 URL 的代理或重定向; +- 测试微信是否已关注测试号。 + +### 自定义域名绑定失败 + +检查同名 A、AAAA、CNAME 记录是否已经存在,域名是否位于当前 Wrangler 登录的 Cloudflare 账号中。 + +### R2 未配置 + +确认 Bucket 存在,并且 `wrangler.jsonc` 中的绑定名称为 `REPORTS`、`bucket_name` 与实际 Bucket 一致。 + +### 卡片过期或图片消失 + +默认有效期为 7 天。Worker 的定时任务会删除过期卡片的元数据、日报和缩略图,这是设计行为。 + +## 安全和隐私注意事项 + +- 日报可能包含敏感群聊内容。只分享你有权分享的内容。 +- 获得分享 URL 的人,在过期前可能查看对应日报;当前实现不是按访问者身份授权。 +- `UPLOAD_TOKEN` 是整个 Worker 的服务级密钥,不是每个用户独立的账号凭据。 +- 不要把 `UPLOAD_TOKEN`、AppSecret 或 Wrangler 凭据提交到 Git。 +- R2 保持私有,不要把 Bucket 直接公开。 +- 建议定期轮换 `UPLOAD_TOKEN`,怀疑泄露时立即轮换。 +- 微信 AppSecret 泄露时,应在微信后台重置,并立即更新 Worker Secret。 +- 自托管者自行承担 Cloudflare 用量、域名、数据合规和微信平台规则相关责任。 + +## 当前实验性限制 + +- 需要用户自己部署,普通用户无法直接开箱使用; +- 使用一个共享的 `UPLOAD_TOKEN`,没有多用户账号系统; +- 分享链接在有效期内属于“知道链接即可访问”; +- 依赖微信 JS-SDK 和测试号能力,微信侧规则变化可能造成失效; +- 当前仅上传 PNG 日报和 JPEG 缩略图; +- 没有管理后台用于列出、提前删除或审计所有卡片; +- 过期清理由定时任务完成,不保证到期瞬间立即删除。 + +## 代码入口 + +- Worker:`services/share-card-worker/src/index.js` +- Worker 配置:`services/share-card-worker/wrangler.jsonc` +- Worker 测试:`services/share-card-worker/test/index.test.js` +- 桌面端上传:`src/main/wechat-share-card-service.ts` +- 本地加密配置:`src/main/wechat-share-config-store.ts` +- 分享弹窗:`src/renderer/src/components/reports/WechatShareCardDialog.tsx` +- 共享类型:`src/shared/wechat-share-card.ts` + +## 参考资料 + +- [微信公众平台接口测试号](https://mp.weixin.qq.com/debug/cgi-bin/sandboxinfo?action=showinfo&t=sandbox/index) +- [微信 JS-SDK 官方文档](https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/JS-SDK.html) +- [Cloudflare Wrangler 命令文档](https://developers.cloudflare.com/workers/wrangler/commands/) +- [Cloudflare R2 Wrangler 命令](https://developers.cloudflare.com/workers/wrangler/commands/r2/) +- [在 Worker 中绑定和使用 R2](https://developers.cloudflare.com/r2/api/workers/workers-api-usage/) +- [Cloudflare Worker Custom Domains](https://developers.cloudflare.com/workers/configuration/routing/custom-domains/) diff --git a/docs/skill/setup-wechat-share-card/SKILL.md b/docs/skill/setup-wechat-share-card/SKILL.md new file mode 100644 index 0000000..11fec1d --- /dev/null +++ b/docs/skill/setup-wechat-share-card/SKILL.md @@ -0,0 +1,92 @@ +--- +name: setup-wechat-share-card +description: 自动配置和部署 TraceMemo 实验性微信分享卡片服务。用户要求启用、部署、修复或迁移微信分享卡片,配置 Cloudflare Worker/R2/Wrangler,设置 UPLOAD_TOKEN、微信测试号 AppID/AppSecret、JS 接口安全域名,或希望由 Codex、Claude Code 等 Agent 代替手工阅读部署文档时使用。 +--- + +# 部署微信分享卡片 + +尽量自行发现项目状态并完成部署。只在自动检查后仍缺少必要信息时,一次性询问用户;不要逐项反复确认。 + +## 安全边界 + +- 不把真实 AppSecret、`UPLOAD_TOKEN`、Cloudflare Token 或微信验证内容写入 Git。 +- `.env.example` 只保存占位符;真实值写入项目根目录 `.env`,该文件必须被 Git 忽略。 +- 不在最终回答中回显 Secret。日志中只报告“已配置/缺失”。 +- `UPLOAD_TOKEN` 默认自动生成,不要求用户提供。 +- AppID/AppSecret 必须来自用户自己的微信测试号或公众号。无法自动获取时才询问。 +- 部署、创建 R2 和写 Secret 属于用户明确请求本 Skill 后的正常动作;不要提交、推送或创建 PR,除非用户另外明确要求。 + +## 自动工作流 + +1. 定位 TraceMemo 仓库根目录。确认存在 `services/share-card-worker/wrangler.jsonc`。 +2. 运行: + + ```bash + bash docs/skill/setup-wechat-share-card/scripts/setup.sh doctor + ``` + +3. 检查根目录 `.env`。脚本会自动复用已有配置并生成缺失的 `WECHAT_SHARE_UPLOAD_TOKEN`。 +4. 如果以下值缺失,只向用户发起一次集中询问: + - 分享域名,例如 `share.example.com`; + - 微信测试号 AppID; + - 微信测试号 AppSecret。 +5. 用户不知道从哪里获取时,告诉他打开: + + ```text + https://mp.weixin.qq.com/debug/cgi-bin/sandboxinfo?action=showinfo&t=sandbox/index + ``` + + 使用微信扫码登录后,复制页面上的 `appID` 和 `appsecret`。提醒用户把分享域名填入“JS 接口安全域名”,不带 `https://` 和路径。 +6. 将缺失值交给交互脚本,不要把 Secret 放进命令行参数: + + ```bash + bash docs/skill/setup-wechat-share-card/scripts/setup.sh configure + ``` + + 该命令通过终端交互收集缺项,AppSecret 使用隐藏输入。 +7. 执行完整部署: + + ```bash + bash docs/skill/setup-wechat-share-card/scripts/setup.sh deploy + ``` + + 脚本会依次: + - 检查或临时下载 Wrangler; + - 启动 Cloudflare OAuth 登录; + - 执行 `whoami`; + - 生成本地 `wrangler.local.jsonc`; + - 创建或复用 R2 Bucket; + - 写入三个 Worker Secret; + - 部署 Worker; + - 检查 `/health` 和微信签名接口。 +8. OAuth 页面出现时,让用户只完成浏览器登录/授权;不要改用 API Token,除非用户主动要求。 +9. 部署后把服务地址告诉用户,并提醒他在 TraceMemo 卡片弹窗粘贴 `.env` 中的 `WECHAT_SHARE_UPLOAD_TOKEN`。优先把 Token 复制到剪贴板,不在聊天中展示: + + ```bash + bash docs/skill/setup-wechat-share-card/scripts/setup.sh copy-token + ``` + +10. 如果微信要求 TXT 验证文件,读取 [references/wechat-domain-verification.md](references/wechat-domain-verification.md),取得用户提供的文件后再修改 Worker。 + +## 决策规则 + +- Wrangler 未安装:优先使用项目依赖;否则通过 `pnpm dlx wrangler@latest` 临时下载,不强制全局安装。 +- `whoami` 已登录正确账号:不要重复登录。 +- R2 已存在:继续,不把“已存在”视为失败。 +- 自定义域名有 A/AAAA/CNAME 冲突:报告准确域名并要求用户选择删除冲突记录或换子域名;不要擅自删除 DNS。 +- HTTP 401:重新同步 `.env` 中的 `WECHAT_SHARE_UPLOAD_TOKEN` 到 Worker,再让用户更新 TraceMemo。 +- “微信 JS-SDK 尚未配置”:重新写入 AppID/AppSecret 并部署。 +- 微信返回 AppID/AppSecret 错误:让用户检查是否来自同一个测试号、AppSecret 是否已重置。 +- 缺少 JS 接口安全域名或测试号关注:这是微信后台操作,明确告诉用户要填写什么,不要假装已完成。 + +## 验证结果 + +完成前必须确认: + +- `wrangler whoami` 成功; +- Worker 部署成功; +- `/health` 返回 `storage: ready`; +- `/api/wx-signature` 返回 `appId`、`timestamp`、`nonceStr`、`signature`; +- Git 扫描未发现 `.env`、真实 AppSecret、上传密钥或用户域名被暂存。 + +详细产品和架构说明见:`docs/deployment/experimental-wechat-share-card.md`。 diff --git a/docs/skill/setup-wechat-share-card/agents/openai.yaml b/docs/skill/setup-wechat-share-card/agents/openai.yaml new file mode 100644 index 0000000..ba1ce73 --- /dev/null +++ b/docs/skill/setup-wechat-share-card/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "部署微信分享卡片" + short_description: "让 Agent 自动完成微信分享卡片服务的配置与部署" + default_prompt: "Use $setup-wechat-share-card to configure and deploy my self-hosted WeChat share-card service with minimal questions." diff --git a/docs/skill/setup-wechat-share-card/references/wechat-domain-verification.md b/docs/skill/setup-wechat-share-card/references/wechat-domain-verification.md new file mode 100644 index 0000000..e3696fd --- /dev/null +++ b/docs/skill/setup-wechat-share-card/references/wechat-domain-verification.md @@ -0,0 +1,15 @@ +# 微信域名验证文件 + +当微信测试号页面要求下载 TXT 文件时: + +1. 向用户索取 TXT 文件本身,或文件名与完整内容。 +2. 不把真实验证内容写进公开仓库历史。 +3. 优先在本地私有配置中注入;若当前 Worker 只能通过源码 Map 返回,则先提醒用户该值会进入工作区,确认仓库发布前必须移除或改造成 Secret/变量。 +4. 验证目标必须是: + + ```text + https://<分享域名>/<微信提供的文件名>.txt + ``` + +5. 返回内容必须是纯文本且与微信提供内容完全一致,不加空格、HTML 或额外换行。 +6. 完成验证后再配置 JS 接口安全域名。安全域名只填主机名,不带协议或路径。 diff --git a/docs/skill/setup-wechat-share-card/scripts/setup.sh b/docs/skill/setup-wechat-share-card/scripts/setup.sh new file mode 100755 index 0000000..3ceb46c --- /dev/null +++ b/docs/skill/setup-wechat-share-card/scripts/setup.sh @@ -0,0 +1,266 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)" +ENV_FILE="$REPO_ROOT/.env" +EXAMPLE_FILE="$REPO_ROOT/.env.example" +WORKER_DIR="$REPO_ROOT/services/share-card-worker" +BASE_CONFIG="$WORKER_DIR/wrangler.jsonc" +LOCAL_CONFIG="$WORKER_DIR/wrangler.local.jsonc" +BUCKET_NAME="wechatexplorer-share-reports" + +cd "$REPO_ROOT" + +fail() { + printf 'ERROR: %s\n' "$*" >&2 + exit 1 +} + +info() { + printf '[share-card] %s\n' "$*" +} + +require_project() { + [[ -f "$BASE_CONFIG" ]] || fail "请在 TraceMemo 仓库根目录运行此脚本" + [[ -f "$EXAMPLE_FILE" ]] || fail "缺少 .env.example" +} + +ensure_env_file() { + if [[ ! -f "$ENV_FILE" ]]; then + cp "$EXAMPLE_FILE" "$ENV_FILE" + chmod 600 "$ENV_FILE" + info "已从 .env.example 创建本机 .env" + fi +} + +read_env_value() { + local key="$1" + local line + line="$(grep -E "^${key}=" "$ENV_FILE" | tail -n 1 || true)" + printf '%s' "${line#*=}" +} + +write_env_value() { + local key="$1" + local value="$2" + local escaped + escaped="$(printf '%s' "$value" | sed 's/[\\&|]/\\&/g')" + if grep -q -E "^${key}=" "$ENV_FILE"; then + sed -i.bak -E "s|^${key}=.*$|${key}=${escaped}|" "$ENV_FILE" + command rm "$ENV_FILE.bak" + else + printf '\n%s=%s\n' "$key" "$value" >> "$ENV_FILE" + fi + chmod 600 "$ENV_FILE" +} + +normalize_domain() { + local value="$1" + value="${value#http://}" + value="${value#https://}" + value="${value%%/*}" + printf '%s' "$value" +} + +ensure_upload_token() { + local token + token="$(read_env_value WECHAT_SHARE_UPLOAD_TOKEN)" + if [[ ${#token} -lt 32 ]]; then + token="$(openssl rand -hex 32)" + write_env_value WECHAT_SHARE_UPLOAD_TOKEN "$token" + info "已生成新的 UPLOAD_TOKEN 并安全写入 .env" + fi +} + +wrangler() { + if [[ -x "$REPO_ROOT/node_modules/.bin/wrangler" ]]; then + "$REPO_ROOT/node_modules/.bin/wrangler" "$@" + elif command -v pnpm >/dev/null 2>&1; then + pnpm dlx wrangler@latest "$@" + elif command -v npx >/dev/null 2>&1; then + npx --yes wrangler@latest "$@" + else + fail "需要 Node.js 以及 pnpm 或 npm 才能运行 Wrangler" + fi +} + +ensure_wrangler_login() { + if wrangler whoami >/dev/null 2>&1; then + wrangler whoami + return + fi + info "即将打开 Cloudflare OAuth 登录,请在浏览器中完成授权" + wrangler login + wrangler whoami +} + +validate_required_config() { + local domain app_id app_secret token + domain="$(normalize_domain "$(read_env_value WECHAT_SHARE_DOMAIN)")" + app_id="$(read_env_value WECHAT_SHARE_APP_ID)" + app_secret="$(read_env_value WECHAT_SHARE_APP_SECRET)" + token="$(read_env_value WECHAT_SHARE_UPLOAD_TOKEN)" + [[ -n "$domain" && "$domain" != "share.example.com" ]] || fail "缺少真实 WECHAT_SHARE_DOMAIN" + [[ -n "$app_id" ]] || fail "缺少 WECHAT_SHARE_APP_ID" + [[ -n "$app_secret" ]] || fail "缺少 WECHAT_SHARE_APP_SECRET" + [[ ${#token} -ge 32 ]] || fail "WECHAT_SHARE_UPLOAD_TOKEN 长度不足" +} + +configure_interactively() { + ensure_env_file + local domain app_id app_secret + domain="$(read_env_value WECHAT_SHARE_DOMAIN)" + if [[ -z "$domain" || "$domain" == "share.example.com" ]]; then + read -r -p '分享域名(例如 share.example.com,不带 https://):' domain + domain="$(normalize_domain "$domain")" + [[ -n "$domain" ]] || fail "分享域名不能为空" + write_env_value WECHAT_SHARE_DOMAIN "$domain" + fi + + app_id="$(read_env_value WECHAT_SHARE_APP_ID)" + if [[ -z "$app_id" ]]; then + read -r -p '微信测试号 AppID:' app_id + [[ -n "$app_id" ]] || fail "AppID 不能为空" + write_env_value WECHAT_SHARE_APP_ID "$app_id" + fi + + app_secret="$(read_env_value WECHAT_SHARE_APP_SECRET)" + if [[ -z "$app_secret" ]]; then + read -r -s -p '微信测试号 AppSecret(输入不会显示):' app_secret + printf '\n' + [[ -n "$app_secret" ]] || fail "AppSecret 不能为空" + write_env_value WECHAT_SHARE_APP_SECRET "$app_secret" + fi + + ensure_upload_token + info "本机配置已准备完成" +} + +generate_local_config() { + local domain + domain="$(normalize_domain "$(read_env_value WECHAT_SHARE_DOMAIN)")" + cat > "$LOCAL_CONFIG" </dev/null 2>&1; then + fail ".env 已被 Git 跟踪,请先从索引移除" + fi + if git diff --cached --name-only | grep -Eq '(^|/)\.env$|wrangler\.local\.jsonc$'; then + fail "敏感本机配置已被暂存,请先取消暂存" + fi +} + +create_bucket_if_needed() { + local output + set +e + output="$(wrangler r2 bucket create "$BUCKET_NAME" --config "$LOCAL_CONFIG" 2>&1)" + local status=$? + set -e + if [[ $status -eq 0 ]]; then + printf '%s\n' "$output" + elif printf '%s' "$output" | grep -Eqi 'already exists|already owned|10004'; then + info "R2 Bucket 已存在,继续部署" + else + printf '%s\n' "$output" >&2 + fail "创建 R2 Bucket 失败" + fi +} + +put_secrets() { + local upload_token app_id app_secret + upload_token="$(read_env_value WECHAT_SHARE_UPLOAD_TOKEN)" + app_id="$(read_env_value WECHAT_SHARE_APP_ID)" + app_secret="$(read_env_value WECHAT_SHARE_APP_SECRET)" + printf '%s' "$upload_token" | wrangler secret put UPLOAD_TOKEN --config "$LOCAL_CONFIG" + printf '%s' "$app_id" | wrangler secret put WECHAT_APP_ID --config "$LOCAL_CONFIG" + printf '%s' "$app_secret" | wrangler secret put WECHAT_APP_SECRET --config "$LOCAL_CONFIG" +} + +verify_service() { + local domain health signature + domain="$(normalize_domain "$(read_env_value WECHAT_SHARE_DOMAIN)")" + health="$(curl -fsS --retry 5 --retry-delay 2 "https://$domain/health")" + printf '%s' "$health" | grep -q '"storage":"ready"' || fail "健康检查未返回 storage: ready" + signature="$(curl -fsS --retry 3 --retry-delay 2 "https://$domain/api/wx-signature?url=https%3A%2F%2F${domain}%2Fhealth")" + printf '%s' "$signature" | grep -q '"signature"' || fail "微信 JS-SDK 签名检查失败:$signature" + info "服务验证成功:https://$domain" +} + +doctor() { + require_project + ensure_env_file + ensure_upload_token + assert_secrets_not_tracked + info "Node: $(node --version 2>/dev/null || printf '未安装')" + info "pnpm: $(pnpm --version 2>/dev/null || printf '未安装')" + if wrangler --version >/dev/null 2>&1; then + info "Wrangler 可用:$(wrangler --version | tail -n 1)" + else + fail "Wrangler 无法运行" + fi + local domain app_id app_secret + domain="$(read_env_value WECHAT_SHARE_DOMAIN)" + app_id="$(read_env_value WECHAT_SHARE_APP_ID)" + app_secret="$(read_env_value WECHAT_SHARE_APP_SECRET)" + [[ -n "$domain" && "$domain" != "share.example.com" ]] && info "分享域名:已配置" || info "分享域名:缺失" + [[ -n "$app_id" ]] && info "微信 AppID:已配置" || info "微信 AppID:缺失" + [[ -n "$app_secret" ]] && info "微信 AppSecret:已配置" || info "微信 AppSecret:缺失" + info "UPLOAD_TOKEN:已配置" +} + +deploy() { + require_project + ensure_env_file + ensure_upload_token + validate_required_config + assert_secrets_not_tracked + ensure_wrangler_login + generate_local_config + create_bucket_if_needed + put_secrets + wrangler deploy --config "$LOCAL_CONFIG" + verify_service +} + +copy_token() { + ensure_env_file + ensure_upload_token + local token + token="$(read_env_value WECHAT_SHARE_UPLOAD_TOKEN)" + if command -v pbcopy >/dev/null 2>&1; then + printf '%s' "$token" | pbcopy + elif command -v wl-copy >/dev/null 2>&1; then + printf '%s' "$token" | wl-copy + elif command -v xclip >/dev/null 2>&1; then + printf '%s' "$token" | xclip -selection clipboard + else + fail "未找到剪贴板工具;请让用户自行从 .env 读取 WECHAT_SHARE_UPLOAD_TOKEN" + fi + info "UPLOAD_TOKEN 已复制到剪贴板" +} + +case "${1:-doctor}" in + doctor) doctor ;; + configure) configure_interactively ;; + deploy) deploy ;; + copy-token) copy_token ;; + *) fail "用法:$0 {doctor|configure|deploy|copy-token}" ;; +esac diff --git a/package.json b/package.json index 2d9e375..fe87adc 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "jsonrepair": "^3.15.0", "koffi": "^3.1.0", "openai": "^6.10.0", + "qrcode": "^1.5.4", "sherpa-onnx-node": "1.13.3", "silk-wasm": "^3.7.1", "wechat-emojis": "^1.0.2" @@ -95,6 +96,7 @@ "@types/archiver": "^8.0.0", "@types/fs-extra": "^11.0.4", "@types/node": "^22.19.1", + "@types/qrcode": "^1.5.6", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", @@ -113,7 +115,8 @@ "sass": "^1.102.0", "typescript": "^5.9.3", "vite": "^7.2.6", - "vitest": "^4.1.10" + "vitest": "^4.1.10", + "wrangler": "^4.28.1" }, "pnpm": { "supportedArchitectures": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a9c754a..6870344 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,7 @@ specifiers: '@types/archiver': ^8.0.0 '@types/fs-extra': ^11.0.4 '@types/node': ^22.19.1 + '@types/qrcode': ^1.5.6 '@types/react': ^19.2.7 '@types/react-dom': ^19.2.3 '@vitejs/plugin-react': ^5.1.1 @@ -45,6 +46,7 @@ specifiers: koffi: ^3.1.0 openai: ^6.10.0 prettier: ^3.7.4 + qrcode: ^1.5.4 react: ^19.2.1 react-dom: ^19.2.1 sass: ^1.102.0 @@ -54,6 +56,7 @@ specifiers: vite: ^7.2.6 vitest: ^4.1.10 wechat-emojis: ^1.0.2 + wrangler: ^4.28.1 dependencies: '@electron-toolkit/preload': 3.0.2_electron@43.1.0 @@ -70,6 +73,7 @@ dependencies: jsonrepair: 3.15.0 koffi: 3.1.0 openai: 6.10.0 + qrcode: 1.5.4 sherpa-onnx-node: 1.13.3 silk-wasm: 3.7.1 wechat-emojis: 1.0.2 @@ -87,6 +91,7 @@ devDependencies: '@types/archiver': 8.0.0 '@types/fs-extra': 11.0.4 '@types/node': 22.19.2 + '@types/qrcode': 1.5.6 '@types/react': 19.2.7 '@types/react-dom': 19.2.3_@types+react@19.2.7 '@vitejs/plugin-react': 5.1.2_vite@7.2.7 @@ -106,6 +111,7 @@ devDependencies: typescript: 5.9.3 vite: 7.2.7_kx7yyowhop237vgn7dp2p54shq vitest: 4.1.10_vx7uwpt5cwqrhusdpoa262p5gm + wrangler: 4.113.0 packages: @@ -362,6 +368,71 @@ packages: css-tree: 3.2.1 dev: true + /@cloudflare/kv-asset-handler/0.5.0: + resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} + engines: {node: '>=22.0.0'} + dev: true + + /@cloudflare/unenv-preset/2.16.1_fo7bsjnzyvfe3xw46kqm46lav4: + resolution: {integrity: sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==} + peerDependencies: + unenv: 2.0.0-rc.24 + workerd: '>1.20260305.0 <2.0.0-0' + peerDependenciesMeta: + workerd: + optional: true + dependencies: + unenv: 2.0.0-rc.24 + workerd: 1.20260721.1 + dev: true + + /@cloudflare/workerd-darwin-64/1.20260721.1: + resolution: {integrity: sha512-VivNMhiEdZIB4JBWxf1RMJGROErv53qmQ+dvhjA1evrCouvqRYW718VqDideU3PSV7Ythl5Df48NqZYWoaEHpQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + dev: true + optional: true + + /@cloudflare/workerd-darwin-arm64/1.20260721.1: + resolution: {integrity: sha512-k7oye1ZiuwnnBBA2eTMduconr/ud5ZxFtRNTsYwMdmJeeeislw2+M72otrHxxvybCP7JWPPlJ38uhfajpcyhOA==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + dev: true + optional: true + + /@cloudflare/workerd-linux-64/1.20260721.1: + resolution: {integrity: sha512-hon0lW4ZQ4boAVgaw+0ZFTNS8v5MWPWvK0HZnt4tDpKYnDUviLZawtUW3KqvFmCQTipVHl1S34j3J8Eqb93hGQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + dev: true + optional: true + + /@cloudflare/workerd-linux-arm64/1.20260721.1: + resolution: {integrity: sha512-nAl+HRQqpX5b7xVwWcvLPZmCk8NQ2yjI0yvJTWcHiRswbMEg1ZZckVmjJUAn0PHzZARbCSyIV7v3UjM+SPRmIQ==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + dev: true + optional: true + + /@cloudflare/workerd-windows-64/1.20260721.1: + resolution: {integrity: sha512-9paFG5cMTKz/CRixnEEnZbe5uvFPBFSDthxJHANfCWhUtBj49GSL1FPIokIg+Q+H8DGJEExU0lL92LtxD0lTxQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + dev: true + optional: true + + /@cspotcode/source-map-support/0.8.1: + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + dev: true + /@csstools/color-helpers/6.1.0: resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==} engines: {node: '>=20.19.0'} @@ -593,6 +664,13 @@ packages: - supports-color dev: true + /@emnapi/runtime/1.11.3: + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + dependencies: + tslib: 2.8.1 + dev: true + optional: true + /@epic-web/invariant/1.0.0: resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} dev: false @@ -605,6 +683,14 @@ packages: dev: true optional: true + /@esbuild/aix-ppc64/0.28.1: + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + dev: true + optional: true + /@esbuild/android-arm/0.25.12: resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} engines: {node: '>=18'} @@ -613,6 +699,14 @@ packages: dev: true optional: true + /@esbuild/android-arm/0.28.1: + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + dev: true + optional: true + /@esbuild/android-arm64/0.25.12: resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} engines: {node: '>=18'} @@ -621,6 +715,14 @@ packages: dev: true optional: true + /@esbuild/android-arm64/0.28.1: + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + dev: true + optional: true + /@esbuild/android-x64/0.25.12: resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} engines: {node: '>=18'} @@ -629,6 +731,14 @@ packages: dev: true optional: true + /@esbuild/android-x64/0.28.1: + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + dev: true + optional: true + /@esbuild/darwin-arm64/0.25.12: resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} engines: {node: '>=18'} @@ -637,6 +747,14 @@ packages: dev: true optional: true + /@esbuild/darwin-arm64/0.28.1: + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + dev: true + optional: true + /@esbuild/darwin-x64/0.25.12: resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} engines: {node: '>=18'} @@ -645,6 +763,14 @@ packages: dev: true optional: true + /@esbuild/darwin-x64/0.28.1: + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + dev: true + optional: true + /@esbuild/freebsd-arm64/0.25.12: resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} engines: {node: '>=18'} @@ -653,6 +779,14 @@ packages: dev: true optional: true + /@esbuild/freebsd-arm64/0.28.1: + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + dev: true + optional: true + /@esbuild/freebsd-x64/0.25.12: resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} engines: {node: '>=18'} @@ -661,6 +795,14 @@ packages: dev: true optional: true + /@esbuild/freebsd-x64/0.28.1: + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + dev: true + optional: true + /@esbuild/linux-arm/0.25.12: resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} engines: {node: '>=18'} @@ -669,6 +811,14 @@ packages: dev: true optional: true + /@esbuild/linux-arm/0.28.1: + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + dev: true + optional: true + /@esbuild/linux-arm64/0.25.12: resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} engines: {node: '>=18'} @@ -677,6 +827,14 @@ packages: dev: true optional: true + /@esbuild/linux-arm64/0.28.1: + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + dev: true + optional: true + /@esbuild/linux-ia32/0.25.12: resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} engines: {node: '>=18'} @@ -685,6 +843,14 @@ packages: dev: true optional: true + /@esbuild/linux-ia32/0.28.1: + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + dev: true + optional: true + /@esbuild/linux-loong64/0.25.12: resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} engines: {node: '>=18'} @@ -693,6 +859,14 @@ packages: dev: true optional: true + /@esbuild/linux-loong64/0.28.1: + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + dev: true + optional: true + /@esbuild/linux-mips64el/0.25.12: resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} engines: {node: '>=18'} @@ -701,6 +875,14 @@ packages: dev: true optional: true + /@esbuild/linux-mips64el/0.28.1: + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + dev: true + optional: true + /@esbuild/linux-ppc64/0.25.12: resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} engines: {node: '>=18'} @@ -709,6 +891,14 @@ packages: dev: true optional: true + /@esbuild/linux-ppc64/0.28.1: + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + dev: true + optional: true + /@esbuild/linux-riscv64/0.25.12: resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} engines: {node: '>=18'} @@ -717,6 +907,14 @@ packages: dev: true optional: true + /@esbuild/linux-riscv64/0.28.1: + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + dev: true + optional: true + /@esbuild/linux-s390x/0.25.12: resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} engines: {node: '>=18'} @@ -725,6 +923,14 @@ packages: dev: true optional: true + /@esbuild/linux-s390x/0.28.1: + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + dev: true + optional: true + /@esbuild/linux-x64/0.25.12: resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} engines: {node: '>=18'} @@ -733,6 +939,14 @@ packages: dev: true optional: true + /@esbuild/linux-x64/0.28.1: + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + dev: true + optional: true + /@esbuild/netbsd-arm64/0.25.12: resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} engines: {node: '>=18'} @@ -741,6 +955,14 @@ packages: dev: true optional: true + /@esbuild/netbsd-arm64/0.28.1: + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + dev: true + optional: true + /@esbuild/netbsd-x64/0.25.12: resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} engines: {node: '>=18'} @@ -749,6 +971,14 @@ packages: dev: true optional: true + /@esbuild/netbsd-x64/0.28.1: + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + dev: true + optional: true + /@esbuild/openbsd-arm64/0.25.12: resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} engines: {node: '>=18'} @@ -757,6 +987,14 @@ packages: dev: true optional: true + /@esbuild/openbsd-arm64/0.28.1: + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + dev: true + optional: true + /@esbuild/openbsd-x64/0.25.12: resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} engines: {node: '>=18'} @@ -765,6 +1003,14 @@ packages: dev: true optional: true + /@esbuild/openbsd-x64/0.28.1: + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + dev: true + optional: true + /@esbuild/openharmony-arm64/0.25.12: resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} engines: {node: '>=18'} @@ -773,6 +1019,14 @@ packages: dev: true optional: true + /@esbuild/openharmony-arm64/0.28.1: + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + dev: true + optional: true + /@esbuild/sunos-x64/0.25.12: resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} engines: {node: '>=18'} @@ -781,6 +1035,14 @@ packages: dev: true optional: true + /@esbuild/sunos-x64/0.28.1: + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + dev: true + optional: true + /@esbuild/win32-arm64/0.25.12: resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} engines: {node: '>=18'} @@ -789,6 +1051,14 @@ packages: dev: true optional: true + /@esbuild/win32-arm64/0.28.1: + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + dev: true + optional: true + /@esbuild/win32-ia32/0.25.12: resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} engines: {node: '>=18'} @@ -797,6 +1067,14 @@ packages: dev: true optional: true + /@esbuild/win32-ia32/0.28.1: + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + dev: true + optional: true + /@esbuild/win32-x64/0.25.12: resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} engines: {node: '>=18'} @@ -805,6 +1083,14 @@ packages: dev: true optional: true + /@esbuild/win32-x64/0.28.1: + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + dev: true + optional: true + /@eslint-community/eslint-utils/4.9.0_eslint@9.39.1: resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -945,6 +1231,230 @@ packages: engines: {node: '>=18.18'} dev: true + /@img/colour/1.1.0: + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + dev: true + + /@img/sharp-darwin-arm64/0.34.5: + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + dev: true + optional: true + + /@img/sharp-darwin-x64/0.34.5: + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + dev: true + optional: true + + /@img/sharp-libvips-darwin-arm64/1.2.4: + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + dev: true + optional: true + + /@img/sharp-libvips-darwin-x64/1.2.4: + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + dev: true + optional: true + + /@img/sharp-libvips-linux-arm/1.2.4: + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + libc: [glibc] + dev: true + optional: true + + /@img/sharp-libvips-linux-arm64/1.2.4: + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + dev: true + optional: true + + /@img/sharp-libvips-linux-ppc64/1.2.4: + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + dev: true + optional: true + + /@img/sharp-libvips-linux-riscv64/1.2.4: + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + dev: true + optional: true + + /@img/sharp-libvips-linux-s390x/1.2.4: + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + dev: true + optional: true + + /@img/sharp-libvips-linux-x64/1.2.4: + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + libc: [glibc] + dev: true + optional: true + + /@img/sharp-libvips-linuxmusl-arm64/1.2.4: + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + libc: [musl] + dev: true + optional: true + + /@img/sharp-libvips-linuxmusl-x64/1.2.4: + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + libc: [musl] + dev: true + optional: true + + /@img/sharp-linux-arm/0.34.5: + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + libc: [glibc] + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + dev: true + optional: true + + /@img/sharp-linux-arm64/0.34.5: + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + dev: true + optional: true + + /@img/sharp-linux-ppc64/0.34.5: + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + dev: true + optional: true + + /@img/sharp-linux-riscv64/0.34.5: + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + dev: true + optional: true + + /@img/sharp-linux-s390x/0.34.5: + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + dev: true + optional: true + + /@img/sharp-linux-x64/0.34.5: + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [glibc] + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + dev: true + optional: true + + /@img/sharp-linuxmusl-arm64/0.34.5: + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [musl] + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + dev: true + optional: true + + /@img/sharp-linuxmusl-x64/0.34.5: + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [musl] + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + dev: true + optional: true + + /@img/sharp-wasm32/0.34.5: + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + dependencies: + '@emnapi/runtime': 1.11.3 + dev: true + optional: true + + /@img/sharp-win32-arm64/0.34.5: + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + dev: true + optional: true + + /@img/sharp-win32-ia32/0.34.5: + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + dev: true + optional: true + + /@img/sharp-win32-x64/0.34.5: + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + dev: true + optional: true + /@isaacs/balanced-match/4.0.1: resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} engines: {node: 20 || >=22} @@ -999,6 +1509,13 @@ packages: '@jridgewell/sourcemap-codec': 1.5.5 dev: true + /@jridgewell/trace-mapping/0.3.9: + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + dev: true + /@koromix/koffi-darwin-arm64/3.1.0: resolution: {integrity: sha512-VEt5r3fXTfbejr83PnuOP0H7s9Zmazcs+lofu96DOcRkistlMsn59wYyWiKpyAjs9PCgm0Ykh62ChZ3CGMmIOg==} cpu: [arm64] @@ -1277,6 +1794,24 @@ packages: playwright: 1.62.1 dev: true + /@poppinss/colors/4.1.6: + resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} + dependencies: + kleur: 4.1.5 + dev: true + + /@poppinss/dumper/0.6.5: + resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==} + dependencies: + '@poppinss/colors': 4.1.6 + '@sindresorhus/is': 7.2.0 + supports-color: 10.2.2 + dev: true + + /@poppinss/exception/1.2.3: + resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + dev: true + /@radix-ui/primitive/1.1.7: resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==} dev: false @@ -1806,6 +2341,15 @@ packages: engines: {node: '>=10'} dev: true + /@sindresorhus/is/7.2.0: + resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} + engines: {node: '>=18'} + dev: true + + /@speed-highlight/core/1.2.24: + resolution: {integrity: sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==} + dev: true + /@standard-schema/spec/1.1.0: resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} dev: true @@ -2028,6 +2572,12 @@ packages: dev: true optional: true + /@types/qrcode/1.5.6: + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} + dependencies: + '@types/node': 22.19.2 + dev: true + /@types/react-dom/19.2.3_@types+react@19.2.7: resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -2376,7 +2926,6 @@ packages: /ansi-regex/5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} - dev: true /ansi-regex/6.2.2: resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} @@ -2388,7 +2937,6 @@ packages: engines: {node: '>=8'} dependencies: color-convert: 2.0.1 - dev: true /ansi-styles/5.2.0: resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} @@ -2722,6 +3270,10 @@ packages: readable-stream: 3.6.2 dev: true + /blake3-wasm/2.1.5: + resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + dev: true + /brace-expansion/1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} dependencies: @@ -2900,6 +3452,11 @@ packages: engines: {node: '>=6'} dev: true + /camelcase/5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + dev: false + /caniuse-lite/1.0.30001760: resolution: {integrity: sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==} dev: true @@ -2968,6 +3525,14 @@ packages: dev: true optional: true + /cliui/6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + dev: false + /cliui/8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -2993,11 +3558,9 @@ packages: engines: {node: '>=7.0.0'} dependencies: color-name: 1.1.4 - dev: true /color-name/1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - dev: true /combined-stream/1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} @@ -3052,6 +3615,11 @@ packages: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} dev: true + /cookie/1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + dev: true + /core-util-is/1.0.2: resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} @@ -3156,6 +3724,11 @@ packages: dependencies: ms: 2.1.3 + /decamelize/1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + dev: false + /decimal.js/10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} dev: true @@ -3219,6 +3792,10 @@ packages: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} dev: false + /dijkstrajs/1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + dev: false + /dir-compare/4.2.0: resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==} dependencies: @@ -3398,7 +3975,6 @@ packages: /emoji-regex/8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - dev: true /emoji-regex/9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} @@ -3434,6 +4010,10 @@ packages: resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} dev: true + /error-stack-parser-es/1.0.5: + resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + dev: true + /es-abstract/1.24.0: resolution: {integrity: sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==} engines: {node: '>= 0.4'} @@ -3597,6 +4177,40 @@ packages: '@esbuild/win32-x64': 0.25.12 dev: true + /esbuild/0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + requiresBuild: true + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + dev: true + /escalade/3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -3896,6 +4510,14 @@ packages: minimatch: 5.1.6 dev: true + /find-up/4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + dev: false + /find-up/5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -4030,7 +4652,6 @@ packages: /get-caller-file/2.0.5: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} - dev: true /get-intrinsic/1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} @@ -4111,7 +4732,7 @@ packages: /glob/8.1.0: resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} engines: {node: '>=12'} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + deprecated: Glob versions prior to v9 are no longer supported dependencies: fs.realpath: 1.0.0 inflight: 1.0.6 @@ -4466,7 +5087,6 @@ packages: /is-fullwidth-code-point/3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} - dev: true /is-generator-function/1.1.2: resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} @@ -4766,6 +5386,11 @@ packages: json-buffer: 3.0.1 dev: true + /kleur/4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + dev: true + /koffi/3.1.0: resolution: {integrity: sha512-0mCvdjTJBXioiaKNz0vajAEdWtfM5qyhVXSq+wQrrU3odzNvl/J7Cqna79QpNo9mfoKpQgGsyFFDRtDACCwGrQ==} optionalDependencies: @@ -4803,6 +5428,13 @@ packages: type-check: 0.4.0 dev: true + /locate-path/5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + dependencies: + p-locate: 4.1.0 + dev: false + /locate-path/6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -4972,6 +5604,22 @@ packages: engines: {node: '>=4'} dev: true + /miniflare/4.20260721.0: + resolution: {integrity: sha512-fBLaCxZ2i/nPH8iyLzvza0C8/sSF4sjD1ma1Skf+pkZVK0TlaW5ujHJlUHwcwR66v2JZt+Q28d4DCX/oaLG0cA==} + engines: {node: '>=22.0.0'} + hasBin: true + dependencies: + '@cspotcode/source-map-support': 0.8.1 + sharp: 0.34.5 + undici: 7.28.0 + workerd: 1.20260721.1 + ws: 8.21.0 + youch: 4.1.0-beta.10 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + dev: true + /minimatch/10.1.1: resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==} engines: {node: 20 || >=22} @@ -5272,6 +5920,13 @@ packages: engines: {node: '>=8'} dev: true + /p-limit/2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + dependencies: + p-try: 2.2.0 + dev: false + /p-limit/3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -5279,6 +5934,13 @@ packages: yocto-queue: 0.1.0 dev: true + /p-locate/4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + dependencies: + p-limit: 2.3.0 + dev: false + /p-locate/5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} @@ -5293,6 +5955,11 @@ packages: aggregate-error: 3.1.0 dev: true + /p-try/2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + dev: false + /package-json-from-dist/1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} dev: true @@ -5317,7 +5984,6 @@ packages: /path-exists/4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} - dev: true /path-is-absolute/1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} @@ -5340,6 +6006,10 @@ packages: minipass: 7.1.2 dev: true + /path-to-regexp/6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + dev: true + /pathe/2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} dev: true @@ -5388,6 +6058,11 @@ packages: xmlbuilder: 15.1.1 dev: true + /pngjs/5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + dev: false + /possible-typed-array-names/1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -5484,6 +6159,16 @@ packages: engines: {node: '>=6'} dev: true + /qrcode/1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + dev: false + /quick-lru/5.1.1: resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} engines: {node: '>=10'} @@ -5654,13 +6339,16 @@ packages: /require-directory/2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} - dev: true /require-from-string/2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} dev: true + /require-main-filename/2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + dev: false + /resedit/1.7.2: resolution: {integrity: sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==} engines: {node: '>=12', npm: '>=6'} @@ -5830,6 +6518,10 @@ packages: engines: {node: '>=10'} hasBin: true + /set-blocking/2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + dev: false + /set-function-length/1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -5861,6 +6553,40 @@ packages: es-object-atoms: 1.1.1 dev: true + /sharp/0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.7.3 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + dev: true + /shebang-command/2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -6086,7 +6812,6 @@ packages: emoji-regex: 8.0.0 is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 - dev: true /string-width/5.1.2: resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} @@ -6171,7 +6896,6 @@ packages: engines: {node: '>=8'} dependencies: ansi-regex: 5.0.1 - dev: true /strip-ansi/7.1.2: resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} @@ -6200,6 +6924,11 @@ packages: transitivePeerDependencies: - supports-color + /supports-color/10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + dev: true + /supports-color/7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -6357,7 +7086,6 @@ packages: /tslib/2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - dev: false /type-check/0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} @@ -6458,13 +7186,18 @@ packages: /undici/7.28.0: resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} engines: {node: '>=20.18.1'} - optional: true /undici/8.9.0: resolution: {integrity: sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==} engines: {node: '>=22.19.0'} dev: true + /unenv/2.0.0-rc.24: + resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + dependencies: + pathe: 2.0.3 + dev: true + /unique-filename/2.0.1: resolution: {integrity: sha512-ODWHtkkdx3IAR+veKxFV+VBkUMcN+FaqzUUd7IZzt+0zhDZFPFxhlqwPF3YQvMHx1TD0tdgYl+kuPnJ8E6ql7A==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} @@ -6758,6 +7491,10 @@ packages: is-weakset: 2.0.4 dev: true + /which-module/2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + dev: false + /which-typed-array/1.1.19: resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==} engines: {node: '>= 0.4'} @@ -6792,6 +7529,52 @@ packages: engines: {node: '>=0.10.0'} dev: true + /workerd/1.20260721.1: + resolution: {integrity: sha512-b/DWhpV0jTudzQpLhDovcOgBz233386q+3Hbari7CLCNT9UXxjQziSTZ9yCoKdT2K3TSx5jrwlOisq8hlLWXYg==} + engines: {node: '>=16'} + hasBin: true + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20260721.1 + '@cloudflare/workerd-darwin-arm64': 1.20260721.1 + '@cloudflare/workerd-linux-64': 1.20260721.1 + '@cloudflare/workerd-linux-arm64': 1.20260721.1 + '@cloudflare/workerd-windows-64': 1.20260721.1 + dev: true + + /wrangler/4.113.0: + resolution: {integrity: sha512-ROGzSloJv0y21It6Oc9LaruNcu1tdiQ/XzL3Jc3YkFjzXEMXzTqVhA8vQaGMTdZHTjFP0PVcwAHNgaw3gXu4wA==} + engines: {node: '>=22.0.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^5.20260721.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + dependencies: + '@cloudflare/kv-asset-handler': 0.5.0 + '@cloudflare/unenv-preset': 2.16.1_fo7bsjnzyvfe3xw46kqm46lav4 + blake3-wasm: 2.1.5 + esbuild: 0.28.1 + miniflare: 4.20260721.0 + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.24 + workerd: 1.20260721.1 + optionalDependencies: + fsevents: 2.3.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + dev: true + + /wrap-ansi/6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + dev: false + /wrap-ansi/7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -6814,6 +7597,19 @@ packages: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} dev: true + /ws/8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + dev: true + /xml-name-validator/5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} @@ -6828,6 +7624,10 @@ packages: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} dev: true + /y18n/4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + dev: false + /y18n/5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -6841,11 +7641,36 @@ packages: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} dev: true + /yargs-parser/18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + dev: false + /yargs-parser/21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} dev: true + /yargs/15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + dev: false + /yargs/17.7.2: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} @@ -6864,6 +7689,23 @@ packages: engines: {node: '>=10'} dev: true + /youch-core/0.3.3: + resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} + dependencies: + '@poppinss/exception': 1.2.3 + error-stack-parser-es: 1.0.5 + dev: true + + /youch/4.1.0-beta.10: + resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} + dependencies: + '@poppinss/colors': 4.1.6 + '@poppinss/dumper': 0.6.5 + '@speed-highlight/core': 1.2.24 + cookie: 1.1.1 + youch-core: 0.3.3 + dev: true + /zip-stream/7.0.5: resolution: {integrity: sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w==} engines: {node: '>=18'} diff --git a/services/share-card-worker/README.md b/services/share-card-worker/README.md new file mode 100644 index 0000000..33665cc --- /dev/null +++ b/services/share-card-worker/README.md @@ -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. diff --git a/services/share-card-worker/package.json b/services/share-card-worker/package.json new file mode 100644 index 0000000..70f81bb --- /dev/null +++ b/services/share-card-worker/package.json @@ -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" + } +} diff --git a/services/share-card-worker/src/index.js b/services/share-card-worker/src/index.js new file mode 100644 index 0000000..b8cf814 --- /dev/null +++ b/services/share-card-worker/src/index.js @@ -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('<', '<') + .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 } diff --git a/services/share-card-worker/test/index.test.js b/services/share-card-worker/test/index.test.js new file mode 100644 index 0000000..cd41fc1 --- /dev/null +++ b/services/share-card-worker/test/index.test.js @@ -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')">&' + ) + 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) +}) diff --git a/services/share-card-worker/wrangler.jsonc b/services/share-card-worker/wrangler.jsonc new file mode 100644 index 0000000..ce86878 --- /dev/null +++ b/services/share-card-worker/wrangler.jsonc @@ -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" + } +} diff --git a/services/share-card-worker/wrangler.verify.jsonc b/services/share-card-worker/wrangler.verify.jsonc new file mode 100644 index 0000000..317dd0f --- /dev/null +++ b/services/share-card-worker/wrangler.verify.jsonc @@ -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" + } +} diff --git a/src/main/index.ts b/src/main/index.ts index 7922484..c373a2b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -135,6 +135,12 @@ import { KnowledgeSearchService } from './knowledge/knowledge-search-service' import { AiSearchPipelineService } from './services/ai-search-pipeline-service' import { runLegacySafeStorageHelper } from './legacy-safe-storage-helper' import { runFirstLaunchMigration } from './app-data-migration' +import { WechatShareConfigStore } from './wechat-share-config-store' +import { WechatShareCardService } from './wechat-share-card-service' +import type { + PublishWechatShareCardRequest, + WechatShareServiceConfig +} from '../shared/wechat-share-card' // electron-vite can close the child's stdout/stderr after spawning Electron. // Plain console.error then throws EPIPE on a closed pipe and crashes the IPC @@ -154,6 +160,8 @@ const imageKeyConfigService = new ImageKeyConfigService() const aiProviderService = new AIProviderService() const keyServiceMac = new KeyServiceMac() const keyServiceWin = new KeyServiceWin() +const wechatShareConfigStore = new WechatShareConfigStore() +const wechatShareCardService = new WechatShareCardService(wechatShareConfigStore) let tray: Tray | null = null let recallArchiveMonitor: RecallArchiveMonitor | null = null let recallProtectionGeneration = 0 @@ -1201,6 +1209,14 @@ app.whenReady().then(async () => { } }) + ipcMain.handle('wechat-share:getConfig', async () => wechatShareConfigStore.status()) + ipcMain.handle('wechat-share:saveConfig', async (_, config: WechatShareServiceConfig) => + wechatShareConfigStore.save(config) + ) + ipcMain.handle('wechat-share:publish', async (_, request: PublishWechatShareCardRequest) => + wechatShareCardService.publish(request) + ) + ipcMain.handle( 'db:getVoiceData', async (_, sessionId: string, localId: number, createTime: number, svrId?: string | number) => { diff --git a/src/main/report-history-service.ts b/src/main/report-history-service.ts index bcfe2a6..4835f3e 100644 --- a/src/main/report-history-service.ts +++ b/src/main/report-history-service.ts @@ -81,6 +81,7 @@ const normalizeRecord = async ( const pngStatus = await fileStatus(record.pngPath) return { ...record, + textModelName: record.textModelName || record.modelName, jsonPath, htmlStatus, pngStatus, @@ -181,6 +182,8 @@ export async function saveGeneratedReport( pngStatus: savedPngPath ? 'ready' : 'missing', imageSize: await readPngSize(savedPngPath), duration: request.duration, + textModelName: request.textModelName || request.modelName, + imageModelName: request.imageModelName, modelName: request.modelName, tokenUsage: request.tokenUsage, fileSize: { diff --git a/src/main/services/ai-provider-service.ts b/src/main/services/ai-provider-service.ts index ca0f4a5..2770441 100644 --- a/src/main/services/ai-provider-service.ts +++ b/src/main/services/ai-provider-service.ts @@ -9,6 +9,7 @@ import type { AIProviderSummary, AiSearchProviderStatus, AIRuntimeModelConfig, + AIVisionRuntimeConfig, AIVisionTestRequest, AIVisionTestResult, LegacyAIConfig @@ -74,6 +75,60 @@ export class AIProviderService { } } + getVisionRuntimeConfig(): AIVisionRuntimeConfig { + const result = this.list() + const defaultProvider = result.providers.find((item) => item.id === result.defaultProviderId) + const defaultModel = defaultProvider?.models.find( + (item) => item.id === defaultProvider.defaultModel + ) + if ( + defaultProvider && + defaultModel && + (defaultProvider.hasApiKey || !needsApiKey(defaultProvider)) && + (defaultModel.capabilities.vision || defaultModel.capabilities.ocr) + ) { + return { + providerId: defaultProvider.id, + providerName: defaultProvider.name, + model: defaultModel.id, + modelName: defaultModel.name || defaultModel.id, + configured: true, + status: defaultProvider.status, + timeoutMs: defaultProvider.advanced.timeoutMs, + source: 'default-model' + } + } + + for (const provider of result.providers) { + if (!provider.hasApiKey && needsApiKey(provider)) continue + const model = + provider.models.find( + (item) => + item.id === provider.defaultModel && (item.capabilities.vision || item.capabilities.ocr) + ) || provider.models.find((item) => item.capabilities.vision || item.capabilities.ocr) + if (!model) continue + return { + providerId: provider.id, + providerName: provider.name, + model: model.id, + modelName: model.name || model.id, + configured: true, + status: provider.status, + timeoutMs: provider.advanced.timeoutMs, + source: 'vision-capability' + } + } + + return { + providerName: '尚未配置', + model: '', + modelName: '尚未验证图片理解模型', + configured: false, + status: 'untested', + source: 'unavailable' + } + } + getAiSearchProviderStatus(providerId?: string): AiSearchProviderStatus { const result = this.list() const provider = diff --git a/src/main/services/image-insight-service.ts b/src/main/services/image-insight-service.ts index 64dcdaf..2c4cbde 100644 --- a/src/main/services/image-insight-service.ts +++ b/src/main/services/image-insight-service.ts @@ -3,7 +3,7 @@ // // 设计原则: // 1. base64 不走 IPC,只在 main 内部流转(renderer 只看到 ImageInsight 结构化结果) -// 2. 同图(imageHash)走缓存,绝不重复调 AI +// 2. 同图(imageHash)在 10 分钟内走缓存,过期后重新调 AI // 3. 失败不抛,日志记录 + 返回原状(不阻塞日报) // 4. 日报最多识别 3 张达到热点门槛的图片;缓存命中即返回,未命中并发调 AI @@ -24,6 +24,7 @@ import type { } from '../../shared/image-insight' import { calculateImageHeatScore, + isFreshImageInsight, isHotImageCandidate } from '../../shared/image-insight' @@ -46,6 +47,13 @@ export interface ImageCandidateInput { interface ProviderServiceLike { list(): ProviderSummaryLike + getVisionRuntimeConfig(): { + providerId?: string + providerName: string + model: string + modelName: string + configured: boolean + } analyzeImage( messages: Array<{ role: string @@ -81,7 +89,7 @@ interface ProviderSummaryLike { class ImageInsightService { private providerService: ProviderServiceLike | null = null private decryptService: DecryptServiceLike | null = null - /** 最近一次实际使用的默认 AI provider/model,仅用于写入分析元数据 */ + /** 最近一次实际使用的视觉 provider/model,仅用于写入分析元数据 */ private runtimeProviderId: string | undefined = undefined private runtimeModelId: string | undefined = undefined @@ -97,13 +105,11 @@ class ImageInsightService { this.runtimeProviderId, this.runtimeModelId ) - // 读取默认 provider/model(后续 analyze 时使用) + // 读取当前视觉 provider/model(后续 analyze 时仍会刷新,避免配置变化后继续用旧模型) try { - const list = deps.providerService.list() - const provider = - list.providers.find((p) => p.id === list.defaultProviderId) || list.providers[0] - this.runtimeProviderId = provider?.id - this.runtimeModelId = provider?.defaultModel + const runtime = deps.providerService.getVisionRuntimeConfig() + this.runtimeProviderId = runtime.providerId + this.runtimeModelId = runtime.model || undefined console.log( '[ImageInsightService] bind loaded default provider=%s model=%s', this.runtimeProviderId, @@ -145,7 +151,7 @@ class ImageInsightService { /** * 主入口:分析一张图片。 - * 1. 通过 imageHash 查缓存,命中即返回 + * 1. 通过 imageHash 查 10 分钟缓存,新鲜则返回 * 2. 未命中:解密图片 → 调 AI → 解析响应 → 落库 → 返回 * 3. 任意步骤失败:记录日志,返回 success=false,**不抛** */ @@ -153,8 +159,15 @@ class ImageInsightService { try { if (!request.force) { const cached = imageInsightsStore.getByHash(request.imageHash) + if (isFreshImageInsight(cached)) { + return { success: true, insight: cached || undefined, fromCache: true } + } if (cached) { - return { success: true, insight: cached, fromCache: true } + console.log( + '[ImageInsightService] cache expired hash=%s ageMs=%d', + request.imageHash, + Date.now() - Number(cached.updatedAt || 0) + ) } } @@ -184,11 +197,24 @@ class ImageInsightService { } ] - const list = this.providerService.list() - const provider = - list.providers.find((item) => item.id === list.defaultProviderId) || list.providers[0] - this.runtimeProviderId = provider?.id - this.runtimeModelId = provider?.defaultModel + const runtime = + request.providerId && request.modelId + ? { + providerId: request.providerId, + model: request.modelId, + configured: true + } + : this.providerService.getVisionRuntimeConfig() + if (!runtime.configured || !runtime.providerId || !runtime.model) { + return { success: false, error: '尚未配置或验证支持图片理解的 AI 模型' } + } + this.runtimeProviderId = runtime.providerId + this.runtimeModelId = runtime.model + console.log( + '[ImageInsightService] analyze using vision provider=%s model=%s', + this.runtimeProviderId, + this.runtimeModelId + ) const result = await this.providerService.analyzeImage(messages, { providerId: this.runtimeProviderId, modelId: this.runtimeModelId @@ -273,7 +299,7 @@ class ImageInsightService { heatScore } const cached = imageInsightsStore.getByHash(hash) - if (cached) candidate.insight = cached + if (isFreshImageInsight(cached) && cached) candidate.insight = cached candidates.push(candidate) } candidates.sort((a, b) => b.heatScore - a.heatScore) @@ -286,7 +312,6 @@ class ImageInsightService { listBySession(sessionId: string, limit?: number): ImageInsight[] { return imageInsightsStore.listBySession(sessionId, limit) } - } export const imageInsightService = new ImageInsightService() diff --git a/src/main/wechat-share-card-service.ts b/src/main/wechat-share-card-service.ts new file mode 100644 index 0000000..aee5561 --- /dev/null +++ b/src/main/wechat-share-card-service.ts @@ -0,0 +1,73 @@ +import { nativeImage } from 'electron' +import fs from 'fs-extra' +import QRCode from 'qrcode' +import type { + PublishWechatShareCardRequest, + PublishWechatShareCardResult +} from '../shared/wechat-share-card' +import { WechatShareConfigStore } from './wechat-share-config-store' + +const MAX_REPORT_BYTES = 25 * 1024 * 1024 + +export class WechatShareCardService { + constructor(private readonly configStore: WechatShareConfigStore) {} + + async publish(request: PublishWechatShareCardRequest): Promise { + try { + const config = await this.configStore.loadRaw() + if (!config) return { success: false, error: '请先配置微信卡片服务' } + const png = await fs.readFile(request.pngPath) + if (!png.length) return { success: false, error: '日报图片为空' } + if (png.length > MAX_REPORT_BYTES) return { success: false, error: '日报图片不能超过 25 MB' } + + const source = nativeImage.createFromBuffer(png) + if (source.isEmpty()) return { success: false, error: '无法读取日报图片' } + const size = source.getSize() + const squareSize = Math.min(size.width, size.height) + const thumbnail = source + .crop({ x: 0, y: 0, width: squareSize, height: squareSize }) + .resize({ width: 360, height: 360, quality: 'best' }) + .toJPEG(84) + + const response = await fetch(`${config.serviceUrl}/api/cards`, { + method: 'POST', + headers: { + Authorization: `Bearer ${config.uploadToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + title: request.title.trim().slice(0, 64), + description: request.description.trim().slice(0, 120), + expiresInDays: Math.max(1, Math.min(30, request.expiresInDays || 7)), + imageBase64: png.toString('base64'), + thumbnailBase64: thumbnail.toString('base64') + }), + signal: AbortSignal.timeout(90_000) + }) + const payload = (await response.json().catch(() => ({}))) as { + cardId?: string + shareUrl?: string + viewUrl?: string + expiresAt?: string + error?: string + } + if (!response.ok || !payload.shareUrl) { + return { success: false, error: payload.error || `卡片服务返回 HTTP ${response.status}` } + } + return { + success: true, + cardId: payload.cardId, + shareUrl: payload.shareUrl, + viewUrl: payload.viewUrl, + expiresAt: payload.expiresAt, + qrCodeDataUrl: await QRCode.toDataURL(payload.shareUrl, { + width: 360, + margin: 2, + errorCorrectionLevel: 'M' + }) + } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + } +} diff --git a/src/main/wechat-share-config-store.ts b/src/main/wechat-share-config-store.ts new file mode 100644 index 0000000..55d5a1c --- /dev/null +++ b/src/main/wechat-share-config-store.ts @@ -0,0 +1,69 @@ +import { app, safeStorage } from 'electron' +import fs from 'fs-extra' +import path from 'path' +import type { + WechatShareServiceConfig, + WechatShareServiceConfigResult +} from '../shared/wechat-share-card' + +const normalizeUrl = (value: string): string => value.trim().replace(/\/+$/, '') + +const validate = (config: WechatShareServiceConfig): string | null => { + try { + const url = new URL(normalizeUrl(config.serviceUrl)) + if (url.protocol !== 'https:' && !['localhost', '127.0.0.1'].includes(url.hostname)) { + return '卡片服务必须使用 HTTPS' + } + } catch { + return '卡片服务地址无效' + } + if (config.uploadToken.trim().length < 24) return '上传密钥至少需要 24 个字符' + return null +} + +export class WechatShareConfigStore { + private get filePath(): string { + return path.join(app.getPath('userData'), 'wechat-share-service.bin') + } + + async loadRaw(): Promise { + if (!(await fs.pathExists(this.filePath))) return null + if (!safeStorage.isEncryptionAvailable()) throw new Error('系统安全存储不可用') + const encrypted = await fs.readFile(this.filePath) + const parsed = JSON.parse(safeStorage.decryptString(encrypted)) as WechatShareServiceConfig + const error = validate(parsed) + if (error) throw new Error(error) + return { serviceUrl: normalizeUrl(parsed.serviceUrl), uploadToken: parsed.uploadToken.trim() } + } + + async status(): Promise { + try { + const config = await this.loadRaw() + return { + success: true, + configured: Boolean(config), + serviceUrl: config?.serviceUrl + } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + } + + async save(config: WechatShareServiceConfig): Promise { + const normalized = { + serviceUrl: normalizeUrl(config.serviceUrl), + uploadToken: config.uploadToken.trim() + } + const error = validate(normalized) + if (error) return { success: false, error } + if (!safeStorage.isEncryptionAvailable()) { + return { success: false, error: '系统安全存储不可用' } + } + await fs.ensureDir(path.dirname(this.filePath)) + await fs.writeFile(this.filePath, safeStorage.encryptString(JSON.stringify(normalized)), { + mode: 0o600 + }) + await fs.chmod(this.filePath, 0o600) + return { success: true, configured: true, serviceUrl: normalized.serviceUrl } + } +} diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index a42bdbd..1b8383a 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -79,6 +79,12 @@ import type { KnowledgeSearchIpcRequest, KnowledgeSearchIpcResult } from '../shared/knowledge' +import type { + PublishWechatShareCardRequest, + PublishWechatShareCardResult, + WechatShareServiceConfig, + WechatShareServiceConfigResult +} from '../shared/wechat-share-card' export type ParsedContent = | { type: 'text'; content: string } @@ -327,6 +333,13 @@ declare global { ) => Promise deleteGeneratedReport: (reportId: string) => Promise revealGroupReport: (filePath: string) => Promise<{ success: boolean; error?: string }> + getWechatShareConfig: () => Promise + saveWechatShareConfig: ( + config: WechatShareServiceConfig + ) => Promise + publishWechatShareCard: ( + request: PublishWechatShareCardRequest + ) => Promise getSavedDbKey: (accountRoot: string) => Promise getDatabaseKeyEnvironment: () => Promise readDatabaseKeyClipboard: () => Promise<{ diff --git a/src/preload/index.ts b/src/preload/index.ts index e645036..a87b5fb 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -54,6 +54,10 @@ import type { KnowledgeSearchIpcRequest, KnowledgeSearchIpcResult } from '../shared/knowledge' +import type { + PublishWechatShareCardRequest, + WechatShareServiceConfig +} from '../shared/wechat-share-card' // 渲染器的自定义 API const api = { @@ -224,6 +228,11 @@ const api = { deleteGeneratedReport: (reportId: string) => ipcRenderer.invoke('report:deleteGenerated', reportId), revealGroupReport: (filePath: string) => ipcRenderer.invoke('report:reveal', filePath), + getWechatShareConfig: () => ipcRenderer.invoke('wechat-share:getConfig'), + saveWechatShareConfig: (config: WechatShareServiceConfig) => + ipcRenderer.invoke('wechat-share:saveConfig', config), + publishWechatShareCard: (request: PublishWechatShareCardRequest) => + ipcRenderer.invoke('wechat-share:publish', request), getSavedDbKey: (accountRoot: string) => ipcRenderer.invoke('key:getSavedDbKey', accountRoot), getDatabaseKeyEnvironment: () => ipcRenderer.invoke('key:getEnvironment'), readDatabaseKeyClipboard: () => ipcRenderer.invoke('key:readClipboardDbKey'), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 7caa11f..94cd631 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -6,7 +6,11 @@ import { ApiWorkspace } from './features/api-center/ApiWorkspace' import { SettingsWorkspace } from './features/settings/SettingsWorkspace' import { AgentHubWorkspace } from './features/agent-hub/AgentHubWorkspace' import type { SettingsCategoryId } from './features/settings/model/types' -import type { AIRuntimeModelConfig } from '../../shared/ai-provider' +import type { + AIProviderSummary, + AIRuntimeModelConfig, + ReportModelChoice +} from '../../shared/ai-provider' import { AppPage } from './components/layout/navigation' import { AiReportWorkspace } from './components/reports/AiReportWorkspace' import { ReportHistorySidebar } from './components/reports/ReportHistorySidebar' @@ -73,6 +77,51 @@ const INITIAL_MESSAGE_COUNT = 20 const MESSAGE_PAGE_SIZE = 100 const MESSAGE_PREFETCH_COUNT = INITIAL_MESSAGE_COUNT + MESSAGE_PAGE_SIZE const EXPORT_PREVIEW_LIMIT = 20 +const REPORT_TEXT_MODEL_STORAGE_KEY = 'group_report_text_model' +const REPORT_VISION_MODEL_STORAGE_KEY = 'group_report_vision_model' + +const reportModelKey = (model: { providerId?: string; model: string }): string => + model.providerId && model.model ? `${model.providerId}::${model.model}` : '' + +const reportProviderConfigured = (provider: AIProviderSummary): boolean => + Boolean(provider.hasApiKey || provider.type === 'ollama' || provider.auth.type === 'none') + +const reportModelChoices = ( + providers: AIProviderSummary[], + capability: 'chat' | 'vision' +): ReportModelChoice[] => + providers.flatMap((provider) => { + if (!reportProviderConfigured(provider)) return [] + return provider.models + .filter((model) => + capability === 'chat' + ? model.capabilities.chat + : model.capabilities.vision || model.capabilities.ocr + ) + .map((model) => ({ + providerId: provider.id, + providerName: provider.name, + model: model.id, + modelName: model.name || model.id, + configured: true as const, + status: provider.status, + timeoutMs: provider.advanced.timeoutMs + })) + }) + +const selectReportModel = ( + choices: ReportModelChoice[], + storageKey: string, + fallback: { providerId?: string; model: string } +): ReportModelChoice | undefined => { + const savedKey = localStorage.getItem(storageKey) || '' + const fallbackKey = reportModelKey(fallback) + return ( + choices.find((choice) => reportModelKey(choice) === savedKey) || + choices.find((choice) => reportModelKey(choice) === fallbackKey) || + choices[0] + ) +} const areMessagesEquivalent = (left: Message[], right: Message[]): boolean => { if (left === right) return true if (left.length !== right.length) return false @@ -209,6 +258,16 @@ function App(): React.ReactElement { configured: false, status: 'untested' }) + const [reportTextModelConfig, setReportTextModelConfig] = useState({ + providerName: '尚未配置', + model: '', + modelName: '尚未选择模型', + configured: false, + status: 'untested' + }) + const [aiVisionModelConfig, setAiVisionModelConfig] = useState() + const [reportTextModelOptions, setReportTextModelOptions] = useState([]) + const [reportVisionModelOptions, setReportVisionModelOptions] = useState([]) const [selfInfo, setSelfInfo] = useState(null) const [isNativeMonitorActive, setIsNativeMonitorActive] = useState(false) const [exportTasks, setExportTasks] = useState(() => { @@ -336,7 +395,25 @@ function App(): React.ReactElement { localStorage.removeItem('ai_model') } } - setAiModelConfig(await window.api.getAIRuntimeConfig()) + const [runtime, visionRuntime, providerList] = await Promise.all([ + window.api.getAIRuntimeConfig(), + window.api.getAIVisionRuntimeConfig(), + window.api.listAIProviders() + ]) + const providers = providerList.success ? providerList.providers : [] + const textChoices = reportModelChoices(providers, 'chat') + const visionChoices = reportModelChoices(providers, 'vision') + const selectedText = selectReportModel(textChoices, REPORT_TEXT_MODEL_STORAGE_KEY, runtime) + const selectedVision = selectReportModel( + visionChoices, + REPORT_VISION_MODEL_STORAGE_KEY, + visionRuntime + ) + setReportTextModelOptions(textChoices) + setReportVisionModelOptions(visionChoices) + setAiModelConfig(runtime) + setReportTextModelConfig(selectedText || runtime) + setAiVisionModelConfig(selectedVision) } catch (error) { console.warn('[AI Provider] 配置加载失败:', error) } @@ -349,7 +426,8 @@ function App(): React.ReactElement { sourceContact: reportSourceContact, summaryDateRange, summaryMessageTypes, - modelConfig: aiModelConfig + modelConfig: reportTextModelConfig, + visionModelConfig: aiVisionModelConfig }) const lastCapturedReportKeyRef = React.useRef('') @@ -1496,7 +1574,10 @@ function App(): React.ReactElement { htmlPath: reportGeneration.reportPaths?.htmlPath, pngPath: reportGeneration.reportPaths?.pngPath, duration: reportGeneration.generationMetadata.durationMs, - modelName: reportGeneration.generationMetadata.modelName || aiModelConfig.model, + textModelName: + reportGeneration.generationMetadata.modelName || reportTextModelConfig.modelName, + imageModelName: aiVisionModelConfig?.modelName || aiVisionModelConfig?.model, + modelName: reportGeneration.generationMetadata.modelName || reportTextModelConfig.modelName, tokenUsage: reportGeneration.generationMetadata.tokenUsage, generationLogs: reportGeneration.generationMetadata.generationLogs, reportSnapshot, @@ -1521,7 +1602,10 @@ function App(): React.ReactElement { void saveReport() }, [ - aiModelConfig.model, + aiVisionModelConfig?.model, + aiVisionModelConfig?.modelName, + reportTextModelConfig.model, + reportTextModelConfig.modelName, reportGeneration.generatedImage, reportGeneration.generationMetadata, reportGeneration.phase, @@ -1689,7 +1773,10 @@ function App(): React.ReactElement { sourceContact={reportSourceContact} summaryDateRange={summaryDateRange} summaryMessageTypes={summaryMessageTypes} - modelConfig={aiModelConfig} + modelConfig={reportTextModelConfig} + visionModelConfig={aiVisionModelConfig} + textModelOptions={reportTextModelOptions} + visionModelOptions={reportVisionModelOptions} rangeMessageCount={reportGeneration.rangeMessages.length} reportMessageCount={reportGeneration.reportMessages.length} messageTypeCounts={reportGeneration.messageTypeCounts} @@ -1702,6 +1789,14 @@ function App(): React.ReactElement { onSummaryDateRangeChange={setSummaryDateRange} onSummaryMessageTypesChange={setSummaryMessageTypes} onOpenModelSettings={openModelSettings} + onTextModelChange={(model) => { + localStorage.setItem(REPORT_TEXT_MODEL_STORAGE_KEY, reportModelKey(model)) + setReportTextModelConfig(model) + }} + onVisionModelChange={(model) => { + localStorage.setItem(REPORT_VISION_MODEL_STORAGE_KEY, reportModelKey(model)) + setAiVisionModelConfig(model) + }} onGenerate={() => { reportGeneration.resetGenerationStatus() void reportGeneration.generate() @@ -1726,7 +1821,7 @@ function App(): React.ReactElement { preparationProgress={reportGeneration.preparationProgress} imageInsightSummary={reportGeneration.imageInsightSummary} canRetryModelStep={reportGeneration.canRetryModelStep} - currentModel={aiModelConfig} + currentModel={reportTextModelConfig} onRetry={(model) => void reportGeneration.retry(model)} onContinueAfterImageFailures={() => void reportGeneration.continueAfterImageFailures()} onCancelAfterImageFailures={reportGeneration.cancelAfterImageFailures} @@ -1765,7 +1860,36 @@ function App(): React.ReactElement { onContactsChange={setContacts} onFilteredContactsChange={setFilteredContacts} onReturnToLogin={handleReturnToLogin} - onAIRuntimeChange={(config: AIRuntimeModelConfig) => setAiModelConfig(config)} + onAIRuntimeChange={(config: AIRuntimeModelConfig) => { + setAiModelConfig(config) + void Promise.all([ + window.api.getAIVisionRuntimeConfig(), + window.api.listAIProviders() + ]) + .then(([visionRuntime, providerList]) => { + const providers = providerList.success ? providerList.providers : [] + const textChoices = reportModelChoices(providers, 'chat') + const visionChoices = reportModelChoices(providers, 'vision') + setReportTextModelOptions(textChoices) + setReportVisionModelOptions(visionChoices) + setReportTextModelConfig( + (current) => + selectReportModel( + textChoices, + REPORT_TEXT_MODEL_STORAGE_KEY, + current.configured ? current : config + ) || config + ) + setAiVisionModelConfig((current) => + selectReportModel( + visionChoices, + REPORT_VISION_MODEL_STORAGE_KEY, + current || visionRuntime + ) + ) + }) + .catch(() => undefined) + }} onNotice={setReportNotice} onOpenSettings={openSettings} onAppearanceChange={handleAppearanceChange} diff --git a/src/renderer/src/components/reports/AiReportWorkspace.tsx b/src/renderer/src/components/reports/AiReportWorkspace.tsx index aa1419c..6146e5f 100644 --- a/src/renderer/src/components/reports/AiReportWorkspace.tsx +++ b/src/renderer/src/components/reports/AiReportWorkspace.tsx @@ -1,5 +1,6 @@ import React, { useMemo, useState } from 'react' import { Contact } from '../../../../shared/types' +import type { ReportModelChoice } from '../../../../shared/ai-provider' import { AiModelConfig, RangeMessageState, @@ -22,6 +23,9 @@ interface AiReportWorkspaceProps { summaryDateRange: SummaryDateRange summaryMessageTypes: SummaryMessageType[] modelConfig: AiModelConfig + visionModelConfig?: ReportModelChoice + textModelOptions: ReportModelChoice[] + visionModelOptions: ReportModelChoice[] rangeMessageCount: number reportMessageCount: number messageTypeCounts: Record @@ -34,6 +38,8 @@ interface AiReportWorkspaceProps { onSummaryDateRangeChange: (value: SummaryDateRange) => void onSummaryMessageTypesChange: (value: SummaryMessageType[]) => void onOpenModelSettings: () => void + onTextModelChange: (model: ReportModelChoice) => void + onVisionModelChange: (model: ReportModelChoice) => void onGenerate: () => void onCloseResult: () => void onCopyImage: () => Promise<{ success: boolean; error?: string }> @@ -69,6 +75,9 @@ export function AiReportWorkspace({ summaryDateRange, summaryMessageTypes, modelConfig, + visionModelConfig, + textModelOptions, + visionModelOptions, rangeMessageCount, reportMessageCount, messageTypeCounts, @@ -81,6 +90,8 @@ export function AiReportWorkspace({ onSummaryDateRangeChange, onSummaryMessageTypesChange, onOpenModelSettings, + onTextModelChange, + onVisionModelChange, onGenerate, onCloseResult, onCopyImage, @@ -171,7 +182,16 @@ export function AiReportWorkspace({ disabled={configDisabled} /> - +

日报生成超时

diff --git a/src/renderer/src/components/reports/ModelSummary.tsx b/src/renderer/src/components/reports/ModelSummary.tsx index 4ff8d9a..3b070a7 100644 --- a/src/renderer/src/components/reports/ModelSummary.tsx +++ b/src/renderer/src/components/reports/ModelSummary.tsx @@ -1,24 +1,87 @@ import React from 'react' +import type { ReportModelChoice } from '../../../../shared/ai-provider' import { AiModelConfig } from '../../hooks/useGroupReportGeneration' interface ModelSummaryProps { config: AiModelConfig + visionConfig?: ReportModelChoice + textModels: ReportModelChoice[] + visionModels: ReportModelChoice[] + disabled?: boolean + onTextModelChange: (model: ReportModelChoice) => void + onVisionModelChange: (model: ReportModelChoice) => void onOpenSettings: () => void } -export function ModelSummary({ config, onOpenSettings }: ModelSummaryProps): React.ReactElement { - const statusText = config.configured ? '配置正常' : '尚未配置' +const modelKey = (model: { providerId?: string; model: string } | undefined): string => + model?.providerId && model.model ? `${model.providerId}::${model.model}` : '' + +const optionLabel = (model: ReportModelChoice): string => + `${model.providerName} · ${model.modelName || model.model}` + +export function ModelSummary({ + config, + visionConfig, + textModels, + visionModels, + disabled = false, + onTextModelChange, + onVisionModelChange, + onOpenSettings +}: ModelSummaryProps): React.ReactElement { + const changeModel = ( + key: string, + models: ReportModelChoice[], + onChange: (model: ReportModelChoice) => void + ): void => { + const selected = models.find((model) => modelKey(model) === key) + if (selected) onChange(selected) + } return (
-
+

模型配置

-

- {config.modelName || config.model || '未选择模型'} · {statusText} -

+
+ + +
+ 图片识别缓存 10 分钟;识图完成后仍由文字总结模型生成日报。
-
diff --git a/src/renderer/src/components/reports/ReportInfoPanel.tsx b/src/renderer/src/components/reports/ReportInfoPanel.tsx index c0210fc..8e88e26 100644 --- a/src/renderer/src/components/reports/ReportInfoPanel.tsx +++ b/src/renderer/src/components/reports/ReportInfoPanel.tsx @@ -108,8 +108,12 @@ export function ReportInfoPanel({ report, onReveal }: ReportInfoPanelProps): Rea {formatDuration(report.duration)}
- 模型 - {modelLabel(report.modelName)} + 文字模型 + {modelLabel(report.textModelName || report.modelName)} +
+
+ 图片模型 + {modelLabel(report.imageModelName)}
Token 来源 diff --git a/src/renderer/src/components/reports/ReportToolbar.tsx b/src/renderer/src/components/reports/ReportToolbar.tsx index a3c8f13..5654d0d 100644 --- a/src/renderer/src/components/reports/ReportToolbar.tsx +++ b/src/renderer/src/components/reports/ReportToolbar.tsx @@ -7,6 +7,7 @@ import { interface ReportToolbarProps { canCopyImage: boolean canReveal: boolean + canShare: boolean canSwitchTemplate: boolean currentTemplateId?: SelectableReportTemplateId isSwitchingTemplate: boolean @@ -14,18 +15,21 @@ interface ReportToolbarProps { onRegenerate: () => void onCopyImage: () => void onReveal: () => void + onShare: () => void } export function ReportToolbar({ canCopyImage, canReveal, + canShare, canSwitchTemplate, currentTemplateId, isSwitchingTemplate, onSwitchTemplate, onRegenerate, onCopyImage, - onReveal + onReveal, + onShare }: ReportToolbarProps): React.ReactElement { const [moreOpen, setMoreOpen] = useState(false) const [templateOpen, setTemplateOpen] = useState(false) @@ -88,6 +92,9 @@ export function ReportToolbar({ +
+ + + {result?.qrCodeDataUrl ? ( +
+ 微信分享二维码 +

使用微信扫码

+

打开页面后点击右上角 ···,发送给好友或群聊。

+ {result.expiresAt && ( + 有效期至 {new Date(result.expiresAt).toLocaleString('zh-CN')} + )} +
+ + +
+
+ ) : ( +
+ +