# 会议纪要分享:落地与上线步骤(2026-09-23) App 里「分享 → 分享链接」发出去的那条链接,之前**从来没有打开过**:域名解析着但没有落地页, 而且链接里带的是用户的完整登录态。这一轮把两件事一起做掉了。 --- ## 一、改了什么 ### 1. 链接里不再是登录 JWT | | 之前 | 现在 | |---|---|---| | URL 最后一段 | `storage.read("logintoken")`,即 `Authorization` 头里那个 JWT | 服务端签发的**分享票据** | | 拿到链接能做什么 | 调任何业务接口、看该账号全部纪要、消费算力 | 只能读**这一条**纪要 | | 失效手段 | 只能改密码 | 默认 7 天自动过期 | 票据是自包含的 HMAC 签名串,不落库: ``` . payload = base64url(JSON{"i":记录id,"u":owner uid,"e":到期unix秒}) sig = base64url(HMAC-SHA256(shareKey, payload)) shareKey = HMAC-SHA256(FIELD_ENCRYPT_KEY, "echomeet.share.v1") ``` 签名密钥从 `FIELD_ENCRYPT_KEY` **派生**而不是直接用:HMAC 单向,从派生 key 推不回原 key, 两个用途不共用同一份秘密。选它是因为它已经在每台机的 `.env` 里,**上线不需要新增任何配置项**。 ⚠️ 代价:票据签发后**无法单独撤销**,只能等过期或换 `FIELD_ENCRYPT_KEY`(那会让全部票据失效)。 这是刻意接受的取舍——要撤销能力时补一张吊销表即可,票据格式不用变。 ### 2. 新增两个接口(`modules/echomeet/api_sharelink.go`) | 接口 | 登录 | 说明 | |---|---|---| | `echomeet_createsharelink` | **要** | 入参 `{id, expiredays}`,回 `{ticket, expiretime}`。签发前先校验这条记录是本人的 | | `echomeet_getsharedrecord` | **不要** | 入参 `{ticket}`,回落地页要展示的只读字段 | 第二个**必须在 gateway 的 `WhiteList` 里**——打开链接的人本来就没登录。已加进 [gateway.yaml.example](../deploy/app/confs/gateway.yaml.example),⚠️ **各机上手工维护的 `confs/gateway.yaml` 要同步加这一行**,漏了就是 `code:18`,表现为「分享链接打开一片空白」。 响应刻意不含 `uid` / `filepath` / `taskid` / `logid` / `*_svc_id`:这是任何人凭链接就能 打开的接口,多回一个字段就是多泄一份内部信息。 ### 3. 落地页嵌进了 gateway 二进制 `modules/gateway/share_page.html` 经 `go:embed` 打进二进制,路由 `GET /share/*any`。 为什么不做成静态文件下发:页面必须跟 `echomeet_getsharedrecord` 的协议同步, 版本对不上就是白页。嵌进来意味着「部署了新镜像 = 页面也是新的」,服务器上不用维护任何文件。 ⚠️ 路由必须是 catch-all。真实链接形如 `/share/ym.ymaikj.com/ymaikj.com/42/`, 路径里的段是**数据**不是文件路径,用 `gin.Static` 会去磁盘找同名文件,一律 404。 --- ## 二、上线状态(2026-09-23 实测) | 步骤 | 状态 | |---|---| | 镜像 `starpivot-app:0.2.5` 构建并推送 | ✅ | | 正式机 `ym-a11` 部署(0.2.4 → 0.2.5) | ✅ 容器 healthy,日志无 panic | | `confs/gateway.yaml` 加 `echomeet_getsharedrecord` 白名单 | ✅ 已改并重启(备份 `gateway.yaml.bak-20260923`) | | `confs/home.yaml` 的 `AppStoreBundleID` 改正 | ✅ 顺带修掉(备份 `home.yaml.bak-20260923`) | | **NPM 加 `share.ymaikj.com` 反代 + 证书** | ❌ **待办,见下** | 容器内实测: ``` /share/... → HTTP 200, 15042 bytes, 会议纪要 echomeet_getsharedrecord + 假票据 → {"code":20,"msg":"分享链接无效或已过期"} ← 白名单生效 echomeet_createsharelink 未登录 → {"code":18,"msg":"NoLogin"} ← 路由已注册 ``` ### 只剩一步:NPM 反代 `share.ymaikj.com` 的 DNS 已指向 `47.116.104.181`,但 NPM 上还没有它的 proxy host (现有四条:`console` / `deepglass` / `mcp` / `ym`)。这一步要登录 NPM 后台操作: 1. **Proxy Hosts → Add Proxy Host** - Domain Names: `share.ymaikj.com` - Scheme `http`,Forward Hostname `ym-a11`,Forward Port `7100` - 打开 Block Common Exploits 2. **SSL 标签页 → Request a new SSL Certificate** - ⚠️ 现有证书都是单域名的(`npm-1`…`npm-mcp`,**没有通配符**),所以必须给 `share.ymaikj.com` 单独签一张 - 勾 Force SSL、HTTP/2 - **不要开 "Use a DNS Challenge"**,保持默认的 HTTP-01 即可 ⚠️ **这台机上签证书用的是 HTTP-01(webroot),不是 DNS challenge。** 2026-09-23 实测:5 张现存证书的 `/etc/letsencrypt/renewal/npm-*.conf` 全部是 `authenticator = webroot`,容器里也没装 `certbot-dns-aliyun` (`certbot plugins` 里搜不到 dns- 开头的任何东西)。最近那张 `mcp.ymaikj.com` 就是这么签成的。所以**不需要**阿里云 AK/SK、不需要填 Propagation Seconds、 不需要先装插件。[运维-容器断网与免费证书排查.md](运维-容器断网与免费证书排查.md) 那篇写的 DNS challenge 是给灵谱那台机的,别照搬到这里。 HTTP-01 的唯一前置是 **80 端口从公网能打到这台机**,且该域名的 DNS 已生效—— 两条都已满足(NPM 监听 `0.0.0.0:80`,`share.ymaikj.com` 解析到 47.116.104.181)。 ⚠️ 顺序不能反:**必须先保存 proxy host、再去签证书**。证书验证时 Let's Encrypt 会来访 `http://share.ymaikj.com/.well-known/acme-challenge/...`,没有这条 proxy host, nginx 上就没有匹配该 `server_name` 的 80 端口配置,验证必然失败。 配完验收: ```bash curl -sI https://share.ymaikj.com/share/ym.ymaikj.com/ymaikj.com/1/x | head -1 # 期望 200 ``` 然后 App 里打开一条纪要 → 分享 → 分享链接 → 发给自己 → 点开。 --- ## 三、原始上线步骤(留档) ### 1. 部署新镜像 ```bash cd deploy/app DEPLOY_KEY=$HOME/Documents/keys/loginscre.pem ./prod-build.sh <版本号> DEPLOY_KEY=$HOME/Documents/keys/loginscre.pem ./prod-deploy.sh ym along <版本号> ``` ### 2. 补 gateway 白名单(**容易漏,漏了整条链路不通**) 正式机 `47.116.104.181` 上 `/home/work/ym-a11/confs/gateway.yaml`,在 `WhiteList` 下加: ```yaml - echomeet_getsharedrecord ``` 改完 `docker restart ym-a11` 即可——`confs/` 是以卷挂进容器的,进程重启就会重读 yaml。 ⚠️ 「必须重建容器」那条规矩只适用于改 **`.env`**(环境变量在容器创建时注入,restart 不会重读)。 ### 3. NPM 加一条反代 `share.ymaikj.com` → `ym-a11:7100`,申请证书。 证书走阿里云 DNS challenge,踩坑记录见 [运维-容器断网与免费证书排查.md](运维-容器断网与免费证书排查.md): `Propagation Seconds` 必须填整数(如 `120`),插件要先手动 `docker exec pip install -i https://mirrors.aliyun.com/pypi/simple certbot-dns-aliyun`。 ### 4. 验收 ```bash # ① 落地页出得来(应返回 200 + HTML) curl -sI https://share.ymaikj.com/share/ym.ymaikj.com/ymaikj.com/1/x | head -3 # ② 免登录接口通了(应返回 code:20 链接无效,而**不是** code:18 NoLogin) curl -sX POST https://ym.ymaikj.com/api/home/echomeet_getsharedrecord \ -H 'Content-Type: application/json' -d '{"ticket":"bad"}' ``` 拿到 `code:18` = 第 2 步的白名单没生效。拿到 `code:20` = 通了(票据确实是假的)。 ③ 真机:App 里打开一条纪要 → 分享 → 分享链接 → 发给自己 → 点开。 ### 5. 客户端要发版 链接格式变了(最后一段 token → ticket),**旧包分享出来的链接落地页打不开** (它会拿旧 JWT 当票据送上去,签名校验必然失败,显示「链接已失效」)。 这不构成兼容问题:分享功能此前从来没有真正可用过,外面不存在能打开的老链接。 --- ## 四、落地页里两处不能动的地方 ### 后端域名白名单 ```js var ALLOWED_HOSTS = ['ym.ymaikj.com', 'ym-dev.ymaikj.com']; ``` `serverHost` 是从 URL 路径里读出来的。**不校验就等于**:任何人构造一条 `/share/<攻击者域名>/.../<票据>` 的链接,就能让打开它的人的浏览器向攻击者的服务器发请求, 顺带把票据送过去。既是钓鱼跳板也是票据泄漏。新增环境要在这里加一行。 ### 先转义再套 Markdown 正则 总结是 Markdown(与客户端 `_exportMarkdownPdf` 同源),页面里有个极简渲染器。 它**先 `esc()` 再套正则**——内容来自用户录音与模型输出,顺序反了就是 XSS。 页面刻意不引第三方 Markdown 库:这页要在国内手机、可能很差的网络下秒开, 为渲染几段标题列表去拉 CDN 不值得,而且 CDN 被墙时整页就废了。 --- ## 五、还没做 - **票据吊销**:见上文取舍。 - **国际化**:落地页是中文的。分享出去的对象大概率与创建者同语言,先不做。 - **`web.ymaikj.com`**:App 里「EAIMAR Web」入口指向它,`apps/web` 仍是空壳,点开打不开。 与本次分享链路无关,但它俩是同一类「域名解析了、东西没做」的欠账。