From 9f105bf7c80162194db6dedefeaa290dfcc25465 Mon Sep 17 00:00:00 2001 From: Rodger-Wang <1367893453@qq.com> Date: Wed, 23 Sep 2026 20:23:56 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=9A=E8=AE=AE=E7=BA=AA=E8=A6=81=E5=88=86?= =?UTF-8?q?=E4=BA=AB=EF=BC=9A=E9=93=BE=E6=8E=A5=E6=9C=80=E5=90=8E=E4=B8=80?= =?UTF-8?q?=E6=AE=B5=E6=94=B9=E6=88=90=E6=9C=8D=E5=8A=A1=E7=AB=AF=E7=AD=BE?= =?UTF-8?q?=E5=8F=91=E7=9A=84=E5=8F=AA=E8=AF=BB=E7=A5=A8=E6=8D=AE=EF=BC=8C?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E6=94=BE=E7=99=BB=E5=BD=95=20JWT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 链接格式 share.ymaikj.com/share/{serverHost}/ymaikj.com/{id}/{ticket}。最后一段 以前放的是 logintoken(完整登录 JWT)——谁拿到链接就拿到了整个账号,转发进群、 被截图、进浏览器历史都等于把账号交出去,而且除了改密码没有任何失效手段。 现在是 HMAC 签名的分享票据:绑定单条记录、只读、默认 7 天过期,密钥由 FIELD_ENCRYPT_KEY 派生(上线不需要新配置项)。 - echomeet_getsharedrecord 必须在 gateway 白名单里——打开链接的人本来就没登录, 漏了是 code:18、表现为「分享链接打开一片空白」。签发端 echomeet_createsharelink 反之,不要加进白名单。 - 落地页嵌在 gateway 二进制里(go:embed),路由 GET /share/*any 必须是 catch-all: 路径里的段是数据不是文件路径,gin.Static 会去磁盘找同名文件、一律 404。 - 落地页的 ALLOWED_HOSTS 白名单不能删:serverHost 来自 URL,不校验就等于「构造 一条链接让别人的浏览器带着票据去请求任意域名」。新增环境要加一行。 - 极简 Markdown 渲染器先 esc() 再套正则,块级语法要匹配转义后的形态(引用块匹配 的是 > 不是 >),写成 > 不报错、只会静默退化成普通段落。 客户端侧:分享前先申请票据并加载提示,拿不到就不分享出一条必然打不开的链接。 Co-Authored-By: Claude Opus 5 (1M context) --- .../client/lib/data/services/network/api.dart | 13 + .../views/bottomSheet/share_bottom_sheet.dart | 37 +- apps/proto/echomeet/echomeet_msg.proto | 38 ++ .../modules/echomeet/api_sharelink.go | 199 ++++++++++ .../modules/echomeet/api_sharelink_test.go | 107 +++++ apps/services/modules/gateway/share_page.go | 37 ++ apps/services/modules/gateway/share_page.html | 316 +++++++++++++++ .../services/modules/gateway/wservice_comp.go | 2 + apps/services/pb/echomeet_msg.pb.go | 370 ++++++++++++++++-- deploy/app/confs/gateway.yaml.example | 6 + ...¼š议纪要分享-落地与上线步骤.md | 196 ++++++++++ 11 files changed, 1290 insertions(+), 31 deletions(-) create mode 100644 apps/services/modules/echomeet/api_sharelink.go create mode 100644 apps/services/modules/echomeet/api_sharelink_test.go create mode 100644 apps/services/modules/gateway/share_page.go create mode 100644 apps/services/modules/gateway/share_page.html create mode 100644 docs/会议纪要分享-落地与上线步骤.md diff --git a/apps/client/lib/data/services/network/api.dart b/apps/client/lib/data/services/network/api.dart index 20f5cfde..ae48cc99 100644 --- a/apps/client/lib/data/services/network/api.dart +++ b/apps/client/lib/data/services/network/api.dart @@ -519,6 +519,19 @@ class Api { ); } + // 为一条会议记录签发分享票据。入参 {id, expiredays?},回 {ticket, expiretime}。 + // + // ⚠️ 分享链接里放的必须是这个票据,**不能再放 logintoken**:那是完整登录态, + // 拿到链接就等于拿到账号,且除了改密码没有任何失效手段。票据只对应这一条记录、 + // 只读、默认 7 天过期(服务端 modules/echomeet/api_sharelink.go)。 + static createShareLinkEchomeet([params]) { + return DioManager().request( + NWMethod.post, + '/api/home/echomeet_createsharelink', + params: params, + ); + } + static readRecordEchomeet([params]) { return DioManager().request( NWMethod.post, diff --git a/apps/client/lib/modules/meeting/views/bottomSheet/share_bottom_sheet.dart b/apps/client/lib/modules/meeting/views/bottomSheet/share_bottom_sheet.dart index 726b773d..6af3bddf 100644 --- a/apps/client/lib/modules/meeting/views/bottomSheet/share_bottom_sheet.dart +++ b/apps/client/lib/modules/meeting/views/bottomSheet/share_bottom_sheet.dart @@ -7,7 +7,6 @@ import 'package:flutter/services.dart'; import 'package:flutter_easyloading/flutter_easyloading.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; -import 'package:get_storage/get_storage.dart'; import 'package:htmltopdfwidgets/htmltopdfwidgets.dart' as pdf; import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; @@ -15,6 +14,7 @@ import 'package:share_plus/share_plus.dart'; import 'package:webview_flutter/webview_flutter.dart'; import '../../../../core/utils/logger.dart'; +import '../../../../data/services/network/api.dart'; import '../../../../data/services/network/dio_manager.dart'; import '../../controllers/meeting_details_controller.dart'; import '../../controllers/mind_map_controller.dart'; @@ -233,20 +233,41 @@ class _ShareBottomSheetState extends State { // 功能说明:生成会议分享链接并通过系统分享面板分享 // 执行流程: // 1. 关闭底部弹窗 - // 2. 从本地存储获取用户登录token + // 2. 向服务端申请**只读分享票据**(echomeet_createsharelink) // 3. 获取会议ID和服务器地址 - // 4. 构建分享URL格式:https://share.ymaikj.com/share/{serverHost}/ymaikj.com/{id}/{token} - // 注:路径第二段是品牌标识、不是要请求的域名,由 share.ymaikj.com 那端解析 + // 4. 构建分享URL:https://share.ymaikj.com/share/{serverHost}/ymaikj.com/{id}/{ticket} + // · 第二段 ymaikj.com 是品牌标识、不是要请求的域名,由落地页解析 + // · {serverHost} 告诉落地页去哪个后端取数据;落地页那边有域名白名单 // 5. 调用SharePlus分享链接 // 6. 分享成功后显示提示 + // + // ⚠️ 最后一段以前放的是 storage.read("logintoken") —— 用户的完整登录 JWT。 + // 链接转发进群、被截图、进浏览器历史,都等于把账号交出去,而且除了改密码 + // 没有任何失效手段。2026-09-23 改成服务端签发的票据:只含这一条记录、 + // 只读、默认 7 天过期。**不要改回去。** onTap: () async { // 在关闭弹窗前捕获分享位置(iPad需要) final origin = _getSharePositionOrigin(); Get.back(); try { - final GetStorage storage = GetStorage(); - String token = storage.read("logintoken") ?? ''; - int id = _controller.meetingData.value.id; + final int id = _controller.meetingData.value.id; + + // 票据要走一次网络,慢网下没有反馈会让人以为按钮没响应 + EasyLoading.show(); + String ticket = ''; + try { + final res = await Api.createShareLinkEchomeet( + {'id': id, 'expiredays': 7}); + ticket = (res?['ticket'] ?? '').toString(); + } finally { + EasyLoading.dismiss(); + } + // 拿不到票据就别分享出一条必然打不开的链接 + if (ticket.isEmpty) { + EasyLoading.showToast( + 'operationFailedPleaseRetry'.tr); // 对应中文:操作失败,请重试 + return; + } // 获取动态服务器地址 final serverUrl = DioManager().baseUrl; @@ -260,7 +281,7 @@ class _ShareBottomSheetState extends State { subject: 'Eaimar WEB,由DeepSound.Ai提供支持-实时记录会议纪要,多语言翻译。', uri: Uri.parse( - 'https://share.ymaikj.com/share/$serverHost/ymaikj.com/$id/$token', + 'https://share.ymaikj.com/share/$serverHost/ymaikj.com/$id/$ticket', ), sharePositionOrigin: origin, )); diff --git a/apps/proto/echomeet/echomeet_msg.proto b/apps/proto/echomeet/echomeet_msg.proto index e98c999f..9a9a12a4 100644 --- a/apps/proto/echomeet/echomeet_msg.proto +++ b/apps/proto/echomeet/echomeet_msg.proto @@ -231,3 +231,41 @@ message EchomeetAliSentence { message EchomeetAliCallbackResp { } + +// ============================== 会议纪要分享 ============================== +// +// 分享链接形如 https://share.ymaikj.com/share/{serverHost}/{brand}/{id}/{ticket} +// +// ⚠️ 2026-09-23 之前客户端把**用户的登录 JWT**直接拼进这个 URL,谁拿到链接就拿到了 +// 整个账号。现在改成服务端签发的分享票据:只含一条记录、只读、有有效期。 +message EchomeetCreateShareLinkReq{ + uint64 id = 1; //@go_tags(`json:"id"`) 会议记录id + int32 expiredays = 2; //@go_tags(`json:"expiredays"`) 有效天数,<=0 用默认(7),上限 30 +} + +message EchomeetCreateShareLinkResp{ + string ticket = 1; //@go_tags(`json:"ticket"`) 分享票据,拼进落地页 URL 的最后一段 + int64 expiretime = 2; //@go_tags(`json:"expiretime"`) 到期时间 unix 秒 +} + +// 落地页按票据取只读内容。**免登录**,须在 gateway 的 WhiteList 里。 +message EchomeetGetSharedRecordReq{ + string ticket = 1; //@go_tags(`json:"ticket"`) 分享票据 +} + +// 只回落地页要展示的字段。刻意不含 uid / filepath / taskid / logid / *_svc_id: +// 这是一个任何人凭链接就能打开的接口,多回一个字段就是多泄一份内部信息。 +message EchomeetGetSharedRecordResp{ + uint64 id = 1; //@go_tags(`json:"id"`) 会议记录id + string title = 2; //@go_tags(`json:"title"`) 标题 + int64 creationtime = 3; //@go_tags(`json:"creationtime"`) 创建时间 unix 秒 + int32 seconds = 4; //@go_tags(`json:"seconds"`) 时长 秒 + string audiourl = 5; //@go_tags(`json:"audiourl"`) 音频url + string original = 6; //@go_tags(`json:"original"`) 听写内容 + string translate = 7; //@go_tags(`json:"translate"`) 翻译 + string overview = 8; //@go_tags(`json:"overview"`) 短总结 + string summary = 9; //@go_tags(`json:"summary"`) 总结 + string formlanguage = 10; //@go_tags(`json:"formlanguage"`) 源语言 + string tolanguage = 11; //@go_tags(`json:"tolanguage"`) 目标语言 + int64 expiretime = 12; //@go_tags(`json:"expiretime"`) 本链接到期时间 unix 秒 +} diff --git a/apps/services/modules/echomeet/api_sharelink.go b/apps/services/modules/echomeet/api_sharelink.go new file mode 100644 index 00000000..8c8d193a --- /dev/null +++ b/apps/services/modules/echomeet/api_sharelink.go @@ -0,0 +1,199 @@ +package echomeet + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "os" + "strings" + "time" + + "yunyan/comm" + "yunyan/pb" +) + +/* +会议纪要分享:服务端签发的只读票据。 + +⚠️ 2026-09-23 之前,客户端把**用户的登录 JWT**(GetStorage 里的 logintoken)直接拼进 +分享 URL 的最后一段。谁拿到那条链接就拿到了整个账号——能调任何业务接口、看全部纪要、 +消费算力,而且除了改密码没有任何失效手段。链接一旦转发进群或被截图就收不回来。 + +现在换成票据:一条票据只对应**一条记录**、只能读、有有效期,且不含任何可用于其它接口的凭据。 + +票据格式(自包含,不落库): + . + payload = base64url(JSON{"i":记录id,"u":owner uid,"e":到期unix秒}) + sig = base64url(HMAC-SHA256(shareKey, payload)) + +为什么不落库:分享是读多写少、量小且可失效期自然收敛的场景,落库要多一张表 + 清理任务, +而票据本身已经带了全部信息。代价是**签发后无法单独撤销**(只能等过期或换密钥), +这是刻意接受的取舍——真要撤销能力时再补一张吊销表即可,票据格式不用变。 +*/ + +const ( + // shareTicketDefaultDays 不指定时的有效期。 + shareTicketDefaultDays = 7 + // shareTicketMaxDays 有效期上限。链接是公开可转发的,无限期等于永久泄露。 + shareTicketMaxDays = 30 + // shareKeyContext 密钥派生的域分隔串。换它等于让全部存量票据立刻失效。 + shareKeyContext = "echomeet.share.v1" +) + +var errShareTicketInvalid = errors.New("分享链接无效或已过期") + +// sharePayload 票据载荷。字段名压到一个字母:它要进 URL,越短越不容易被中间环节截断。 +type sharePayload struct { + Id uint64 `json:"i"` + Uid string `json:"u"` + Expire int64 `json:"e"` +} + +// shareKey 从 FIELD_ENCRYPT_KEY 派生分享票据的签名密钥。 +// +// 为什么派生而不是直接用:FIELD_ENCRYPT_KEY 是 svc_config 里第三方凭据的加密密钥, +// 拿它直接签票据会让两个用途共用同一份秘密。HMAC 是单向的,从派生key 推不回原 key。 +// +// 为什么选 FIELD_ENCRYPT_KEY 而不是新加一个配置项:它已经在每台机的 .env 里 +// (console 与各业务服务本就必须配同一把),新增配置项意味着上线时漏配一处就整个功能不可用, +// 而且漏配的表现是「分享按钮点了报错」这种要到真机才发现的问题。 +func shareKey() ([]byte, error) { + base := strings.TrimSpace(os.Getenv("FIELD_ENCRYPT_KEY")) + if base == "" { + // 刻意不退化成空密钥签名:那样任何人都能自己造票据读任意记录。 + return nil, errors.New("缺少 FIELD_ENCRYPT_KEY,无法签发分享票据") + } + m := hmac.New(sha256.New, []byte(base)) + m.Write([]byte(shareKeyContext)) + return m.Sum(nil), nil +} + +// signShareTicket 签发票据。 +func signShareTicket(p sharePayload) (string, error) { + key, err := shareKey() + if err != nil { + return "", err + } + raw, err := json.Marshal(p) + if err != nil { + return "", err + } + body := base64.RawURLEncoding.EncodeToString(raw) + m := hmac.New(sha256.New, key) + m.Write([]byte(body)) + return body + "." + base64.RawURLEncoding.EncodeToString(m.Sum(nil)), nil +} + +// parseShareTicket 校验并解出票据载荷。 +// +// ⚠️ 「签名不对」「格式不对」「已过期」一律回同一个 errShareTicketInvalid。 +// 分开回等于告诉攻击者「这条票据是真的、只是过期了」,同 recordErr 的理由。 +func parseShareTicket(ticket string) (*sharePayload, error) { + ticket = strings.TrimSpace(ticket) + i := strings.LastIndex(ticket, ".") + if i <= 0 || i == len(ticket)-1 { + return nil, errShareTicketInvalid + } + body, sig := ticket[:i], ticket[i+1:] + key, err := shareKey() + if err != nil { + return nil, err + } + m := hmac.New(sha256.New, key) + m.Write([]byte(body)) + want := m.Sum(nil) + got, err := base64.RawURLEncoding.DecodeString(sig) + // hmac.Equal 是常数时间比较:用 == 比会让攻击者靠计时逐字节试出正确签名。 + if err != nil || !hmac.Equal(want, got) { + return nil, errShareTicketInvalid + } + raw, err := base64.RawURLEncoding.DecodeString(body) + if err != nil { + return nil, errShareTicketInvalid + } + p := &sharePayload{} + if err = json.Unmarshal(raw, p); err != nil { + return nil, errShareTicketInvalid + } + if p.Id == 0 || p.Uid == "" || p.Expire <= 0 || time.Now().Unix() > p.Expire { + return nil, errShareTicketInvalid + } + return p, nil +} + +// @Summary 创建会议纪要分享链接 +// @Description 为自己的一条会议记录签发只读分享票据,客户端拼成落地页 URL +// @Tags Echomeet +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param user body pb.EchomeetCreateShareLinkReq true "请求数据" +// @Success 200 {object} comm.HttpResult{data=pb.EchomeetCreateShareLinkResp} "响应数据" +// @Router /api/home/echomeet_createsharelink [post] +func (this *apiComp) CreateShareLink(session comm.IUserSession, req *pb.EchomeetCreateShareLinkReq) (resp *pb.EchomeetCreateShareLinkResp, errdata *pb.ErrorData) { + uid, errdata := requireUID(session) + if errdata != nil { + return + } + // 先确认这条记录确实是本人的:签发前不校验的话,改个 id 就能给别人的纪要签出公开链接。 + if _, err := this.module.model.getrecordforuid(uid, req.Id); err != nil { + errdata = recordErr(err) + return + } + days := int(req.Expiredays) + if days <= 0 { + days = shareTicketDefaultDays + } + if days > shareTicketMaxDays { + days = shareTicketMaxDays + } + expire := time.Now().AddDate(0, 0, days).Unix() + ticket, err := signShareTicket(sharePayload{Id: req.Id, Uid: uid, Expire: expire}) + if err != nil { + errdata = &pb.ErrorData{Code: pb.ErrorCode_SystemError, Message: err.Error()} + return + } + resp = &pb.EchomeetCreateShareLinkResp{Ticket: ticket, Expiretime: expire} + return +} + +// @Summary 按分享票据读取会议纪要 +// @Description 落地页调用,**免登录**,须在 gateway 的 WhiteList 里 +// @Tags Echomeet +// @Accept json +// @Produce json +// @Param user body pb.EchomeetGetSharedRecordReq true "请求数据" +// @Success 200 {object} comm.HttpResult{data=pb.EchomeetGetSharedRecordResp} "响应数据" +// @Router /api/home/echomeet_getsharedrecord [post] +func (this *apiComp) GetSharedRecord(session comm.IUserSession, req *pb.EchomeetGetSharedRecordReq) (resp *pb.EchomeetGetSharedRecordResp, errdata *pb.ErrorData) { + // 注意:这里**不能**调 requireUID —— 本接口的全部意义就是让没登录的人也能打开链接。 + p, err := parseShareTicket(req.Ticket) + if err != nil { + errdata = &pb.ErrorData{Code: pb.ErrorCode_StateInvalid, Message: err.Error()} + return + } + // 仍按 (uid, id) 查:票据签发时绑定了 owner,记录易主或被删后票据自然失效。 + model, err := this.module.model.getrecordforuid(p.Uid, p.Id) + if err != nil { + // 记录已删时同样回「链接无效」,不区分——链接是公开的,不该成为探测记录存在性的入口。 + errdata = &pb.ErrorData{Code: pb.ErrorCode_StateInvalid, Message: errShareTicketInvalid.Error()} + return + } + resp = &pb.EchomeetGetSharedRecordResp{ + Id: model.Id, + Title: model.Title, + Creationtime: model.Creationtime, + Seconds: model.Seconds, + Audiourl: model.Audiourl, + Original: model.Original, + Translate: model.Translate, + Overview: model.Overview, + Summary: model.Summary, + Formlanguage: model.Formlanguage, + Tolanguage: model.Tolanguage, + Expiretime: p.Expire, + } + return +} diff --git a/apps/services/modules/echomeet/api_sharelink_test.go b/apps/services/modules/echomeet/api_sharelink_test.go new file mode 100644 index 00000000..3f5f8643 --- /dev/null +++ b/apps/services/modules/echomeet/api_sharelink_test.go @@ -0,0 +1,107 @@ +package echomeet + +import ( + "encoding/base64" + "encoding/json" + "os" + "strings" + "testing" + "time" +) + +// 票据是公开链接里的唯一凭据,下面每条都是「错了就等于任何人能读任意纪要」级别的性质, +// 所以全部钉死在测试里。 +func TestShareTicketRoundTrip(t *testing.T) { + os.Setenv("FIELD_ENCRYPT_KEY", "test-field-key-0123456789") + defer os.Unsetenv("FIELD_ENCRYPT_KEY") + + exp := time.Now().Add(time.Hour).Unix() + ticket, err := signShareTicket(sharePayload{Id: 42, Uid: "1234567890", Expire: exp}) + if err != nil { + t.Fatalf("签发失败: %v", err) + } + got, err := parseShareTicket(ticket) + if err != nil { + t.Fatalf("校验失败: %v", err) + } + if got.Id != 42 || got.Uid != "1234567890" || got.Expire != exp { + t.Fatalf("载荷不一致: %+v", got) + } +} + +func TestShareTicketRejects(t *testing.T) { + os.Setenv("FIELD_ENCRYPT_KEY", "test-field-key-0123456789") + defer os.Unsetenv("FIELD_ENCRYPT_KEY") + + good, _ := signShareTicket(sharePayload{Id: 42, Uid: "u1", Expire: time.Now().Add(time.Hour).Unix()}) + body := good[:strings.LastIndex(good, ".")] + + // 改载荷不改签名:这是最直接的攻击——把 id 换成别人的记录。 + tampered := base64.RawURLEncoding.EncodeToString([]byte(`{"i":43,"u":"u1","e":9999999999}`)) + cases := map[string]string{ + "空串": "", + "没有分隔点": body, + "签名被截断": body + ".", + "签名乱填": body + ".AAAA", + "载荷被篡改": tampered + good[strings.LastIndex(good, "."):], + "整段不是base64": "!!!.@@@", + } + for name, tk := range cases { + if _, err := parseShareTicket(tk); err == nil { + t.Errorf("%s: 本该被拒,却通过了", name) + } + } + + // 过期票据必须拒绝:签名是对的,只有时间不对。 + expired, _ := signShareTicket(sharePayload{Id: 42, Uid: "u1", Expire: time.Now().Add(-time.Second).Unix()}) + if _, err := parseShareTicket(expired); err == nil { + t.Error("过期票据本该被拒") + } +} + +// 换密钥必须让旧票据全部失效——否则「换密钥」这个应急手段是假的。 +func TestShareTicketKeyIsolation(t *testing.T) { + os.Setenv("FIELD_ENCRYPT_KEY", "key-A") + ticket, err := signShareTicket(sharePayload{Id: 1, Uid: "u", Expire: time.Now().Add(time.Hour).Unix()}) + if err != nil { + t.Fatal(err) + } + os.Setenv("FIELD_ENCRYPT_KEY", "key-B") + defer os.Unsetenv("FIELD_ENCRYPT_KEY") + if _, err = parseShareTicket(ticket); err == nil { + t.Fatal("换密钥后旧票据仍然有效") + } +} + +// 没有 FIELD_ENCRYPT_KEY 时必须报错,绝不能退化成用空密钥签名(那样谁都能自己造票据)。 +func TestShareTicketRefusesEmptyKey(t *testing.T) { + old := os.Getenv("FIELD_ENCRYPT_KEY") + os.Unsetenv("FIELD_ENCRYPT_KEY") + defer os.Setenv("FIELD_ENCRYPT_KEY", old) + if _, err := signShareTicket(sharePayload{Id: 1, Uid: "u", Expire: 1}); err == nil { + t.Fatal("缺密钥时本该拒绝签发") + } +} + +// 票据里不该出现任何登录凭据的痕迹:它进 URL、会被转发、会进浏览器历史。 +func TestShareTicketCarriesOnlyScopedPayload(t *testing.T) { + os.Setenv("FIELD_ENCRYPT_KEY", "test-field-key-0123456789") + defer os.Unsetenv("FIELD_ENCRYPT_KEY") + ticket, _ := signShareTicket(sharePayload{Id: 7, Uid: "u7", Expire: time.Now().Add(time.Hour).Unix()}) + raw, err := base64.RawURLEncoding.DecodeString(ticket[:strings.LastIndex(ticket, ".")]) + if err != nil { + t.Fatal(err) + } + m := map[string]any{} + if err = json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + if len(m) != 3 { + t.Fatalf("载荷字段数变了(%d),新增字段前先确认它可以公开: %v", len(m), m) + } + for _, k := range []string{"i", "u", "e"} { + if _, ok := m[k]; !ok { + t.Fatalf("载荷缺字段 %s: %v", k, m) + } + } +} diff --git a/apps/services/modules/gateway/share_page.go b/apps/services/modules/gateway/share_page.go new file mode 100644 index 00000000..714e8083 --- /dev/null +++ b/apps/services/modules/gateway/share_page.go @@ -0,0 +1,37 @@ +package gateway + +import ( + _ "embed" + "net/http" + + "github.com/gin-gonic/gin" +) + +// 会议纪要分享落地页。 +// +// 为什么嵌进二进制而不是当静态文件下发: +// 这个页面必须跟着 echomeet_getsharedrecord 的协议走,两者版本对不上就是白页。 +// 嵌进来意味着「部署了新镜像 = 页面也是新的」,服务器上不需要维护任何文件, +// prod-deploy.sh 也不用多下发一份(confs/*.yaml 那套人工维护的坑已经够多了)。 +// +// 上线只剩一步:NPM 上把 share.ymaikj.com 反代到 ym-a11:7100(即本 gateway),申请证书。 +// +//go:embed share_page.html +var sharePageHTML []byte + +// sharePage 把落地页返回给任意 /share/... 路径。 +// +// ⚠️ 必须是 catch-all:真实链接形如 /share/ym.ymaikj.com/ymaikj.com/42/, +// 路径里的段是**数据**不是文件路径。用 gin.Static 会去磁盘上找同名文件,一律 404。 +// 解析工作全在页面里的 JS 做(它要按 location.pathname 取 serverHost/id/ticket)。 +func (this *WSServiceComp) sharePage(c *gin.Context) { + // 不缓存:页面里写死了接口协议,缓存住旧版会在协议变更后变成白页,而用户 + // 完全没有手段让它刷新(分享链接是别人发来的,不会有人去硬刷)。 + c.Header("Cache-Control", "no-cache, must-revalidate") + // 落地页不该被别的站点嵌进 iframe 里套壳。 + c.Header("X-Frame-Options", "SAMEORIGIN") + c.Header("X-Content-Type-Options", "nosniff") + // 别把带票据的完整 URL 经 Referer 泄漏给第三方(音频在 OSS 上,是跨站请求)。 + c.Header("Referrer-Policy", "no-referrer") + c.Data(http.StatusOK, "text/html; charset=utf-8", sharePageHTML) +} diff --git a/apps/services/modules/gateway/share_page.html b/apps/services/modules/gateway/share_page.html new file mode 100644 index 00000000..95a55cd5 --- /dev/null +++ b/apps/services/modules/gateway/share_page.html @@ -0,0 +1,316 @@ + + + + + + +会议纪要 + + + +
+
正在打开…
+
+ + + + diff --git a/apps/services/modules/gateway/wservice_comp.go b/apps/services/modules/gateway/wservice_comp.go index dc20ae64..9d590796 100644 --- a/apps/services/modules/gateway/wservice_comp.go +++ b/apps/services/modules/gateway/wservice_comp.go @@ -63,6 +63,8 @@ func (this *WSServiceComp) Init(service core.IService, module core.IModule, comp this.gin.Static("/avatar", "./avatar") this.gin.Static("/console", "./console") this.gin.Static("/docs", "./docs") + // 会议纪要分享落地页(share.ymaikj.com 反代到本端口)。catch-all,理由见 share_page.go。 + this.gin.GET("/share/*any", this.sharePage) this.gin.Any("/api/:param1/:param2", this.api) this.gin.Any("/web/:param1/:param2", this.web) this.gin.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerfiles.Handler)) diff --git a/apps/services/pb/echomeet_msg.pb.go b/apps/services/pb/echomeet_msg.pb.go index b0f6297f..5ad28cf4 100644 --- a/apps/services/pb/echomeet_msg.pb.go +++ b/apps/services/pb/echomeet_msg.pb.go @@ -2133,6 +2133,296 @@ func (*EchomeetAliCallbackResp) Descriptor() ([]byte, []int) { return file_echomeet_echomeet_msg_proto_rawDescGZIP(), []int{40} } +// ============================== 会议纪要分享 ============================== +// +// 分享链接形如 https://share.ymaikj.com/share/{serverHost}/{brand}/{id}/{ticket} +// +// ⚠️ 2026-09-23 之前客户端把**用户的登录 JWT**直接拼进这个 URL,谁拿到链接就拿到了 +// +// 整个账号。现在改成服务端签发的分享票据:只含一条记录、只读、有有效期。 +type EchomeetCreateShareLinkReq struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id uint64 `protobuf:"varint,1,opt,name=id,proto3" json:"id"` //@go_tags(`json:"id"`) 会议记录id + Expiredays int32 `protobuf:"varint,2,opt,name=expiredays,proto3" json:"expiredays"` //@go_tags(`json:"expiredays"`) 有效天数,<=0 用默认(7),上限 30 + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EchomeetCreateShareLinkReq) Reset() { + *x = EchomeetCreateShareLinkReq{} + mi := &file_echomeet_echomeet_msg_proto_msgTypes[41] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EchomeetCreateShareLinkReq) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EchomeetCreateShareLinkReq) ProtoMessage() {} + +func (x *EchomeetCreateShareLinkReq) ProtoReflect() protoreflect.Message { + mi := &file_echomeet_echomeet_msg_proto_msgTypes[41] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EchomeetCreateShareLinkReq.ProtoReflect.Descriptor instead. +func (*EchomeetCreateShareLinkReq) Descriptor() ([]byte, []int) { + return file_echomeet_echomeet_msg_proto_rawDescGZIP(), []int{41} +} + +func (x *EchomeetCreateShareLinkReq) GetId() uint64 { + if x != nil { + return x.Id + } + return 0 +} + +func (x *EchomeetCreateShareLinkReq) GetExpiredays() int32 { + if x != nil { + return x.Expiredays + } + return 0 +} + +type EchomeetCreateShareLinkResp struct { + state protoimpl.MessageState `protogen:"open.v1"` + Ticket string `protobuf:"bytes,1,opt,name=ticket,proto3" json:"ticket"` //@go_tags(`json:"ticket"`) 分享票据,拼进落地页 URL 的最后一段 + Expiretime int64 `protobuf:"varint,2,opt,name=expiretime,proto3" json:"expiretime"` //@go_tags(`json:"expiretime"`) 到期时间 unix 秒 + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EchomeetCreateShareLinkResp) Reset() { + *x = EchomeetCreateShareLinkResp{} + mi := &file_echomeet_echomeet_msg_proto_msgTypes[42] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EchomeetCreateShareLinkResp) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EchomeetCreateShareLinkResp) ProtoMessage() {} + +func (x *EchomeetCreateShareLinkResp) ProtoReflect() protoreflect.Message { + mi := &file_echomeet_echomeet_msg_proto_msgTypes[42] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EchomeetCreateShareLinkResp.ProtoReflect.Descriptor instead. +func (*EchomeetCreateShareLinkResp) Descriptor() ([]byte, []int) { + return file_echomeet_echomeet_msg_proto_rawDescGZIP(), []int{42} +} + +func (x *EchomeetCreateShareLinkResp) GetTicket() string { + if x != nil { + return x.Ticket + } + return "" +} + +func (x *EchomeetCreateShareLinkResp) GetExpiretime() int64 { + if x != nil { + return x.Expiretime + } + return 0 +} + +// 落地页按票据取只读内容。**免登录**,须在 gateway 的 WhiteList 里。 +type EchomeetGetSharedRecordReq struct { + state protoimpl.MessageState `protogen:"open.v1"` + Ticket string `protobuf:"bytes,1,opt,name=ticket,proto3" json:"ticket"` //@go_tags(`json:"ticket"`) 分享票据 + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EchomeetGetSharedRecordReq) Reset() { + *x = EchomeetGetSharedRecordReq{} + mi := &file_echomeet_echomeet_msg_proto_msgTypes[43] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EchomeetGetSharedRecordReq) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EchomeetGetSharedRecordReq) ProtoMessage() {} + +func (x *EchomeetGetSharedRecordReq) ProtoReflect() protoreflect.Message { + mi := &file_echomeet_echomeet_msg_proto_msgTypes[43] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EchomeetGetSharedRecordReq.ProtoReflect.Descriptor instead. +func (*EchomeetGetSharedRecordReq) Descriptor() ([]byte, []int) { + return file_echomeet_echomeet_msg_proto_rawDescGZIP(), []int{43} +} + +func (x *EchomeetGetSharedRecordReq) GetTicket() string { + if x != nil { + return x.Ticket + } + return "" +} + +// 只回落地页要展示的字段。刻意不含 uid / filepath / taskid / logid / *_svc_id: +// 这是一个任何人凭链接就能打开的接口,多回一个字段就是多泄一份内部信息。 +type EchomeetGetSharedRecordResp struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id uint64 `protobuf:"varint,1,opt,name=id,proto3" json:"id"` //@go_tags(`json:"id"`) 会议记录id + Title string `protobuf:"bytes,2,opt,name=title,proto3" json:"title"` //@go_tags(`json:"title"`) 标题 + Creationtime int64 `protobuf:"varint,3,opt,name=creationtime,proto3" json:"creationtime"` //@go_tags(`json:"creationtime"`) 创建时间 unix 秒 + Seconds int32 `protobuf:"varint,4,opt,name=seconds,proto3" json:"seconds"` //@go_tags(`json:"seconds"`) 时长 秒 + Audiourl string `protobuf:"bytes,5,opt,name=audiourl,proto3" json:"audiourl"` //@go_tags(`json:"audiourl"`) 音频url + Original string `protobuf:"bytes,6,opt,name=original,proto3" json:"original"` //@go_tags(`json:"original"`) 听写内容 + Translate string `protobuf:"bytes,7,opt,name=translate,proto3" json:"translate"` //@go_tags(`json:"translate"`) 翻译 + Overview string `protobuf:"bytes,8,opt,name=overview,proto3" json:"overview"` //@go_tags(`json:"overview"`) 短总结 + Summary string `protobuf:"bytes,9,opt,name=summary,proto3" json:"summary"` //@go_tags(`json:"summary"`) 总结 + Formlanguage string `protobuf:"bytes,10,opt,name=formlanguage,proto3" json:"formlanguage"` //@go_tags(`json:"formlanguage"`) 源语言 + Tolanguage string `protobuf:"bytes,11,opt,name=tolanguage,proto3" json:"tolanguage"` //@go_tags(`json:"tolanguage"`) 目标语言 + Expiretime int64 `protobuf:"varint,12,opt,name=expiretime,proto3" json:"expiretime"` //@go_tags(`json:"expiretime"`) 本链接到期时间 unix 秒 + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EchomeetGetSharedRecordResp) Reset() { + *x = EchomeetGetSharedRecordResp{} + mi := &file_echomeet_echomeet_msg_proto_msgTypes[44] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EchomeetGetSharedRecordResp) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EchomeetGetSharedRecordResp) ProtoMessage() {} + +func (x *EchomeetGetSharedRecordResp) ProtoReflect() protoreflect.Message { + mi := &file_echomeet_echomeet_msg_proto_msgTypes[44] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EchomeetGetSharedRecordResp.ProtoReflect.Descriptor instead. +func (*EchomeetGetSharedRecordResp) Descriptor() ([]byte, []int) { + return file_echomeet_echomeet_msg_proto_rawDescGZIP(), []int{44} +} + +func (x *EchomeetGetSharedRecordResp) GetId() uint64 { + if x != nil { + return x.Id + } + return 0 +} + +func (x *EchomeetGetSharedRecordResp) GetTitle() string { + if x != nil { + return x.Title + } + return "" +} + +func (x *EchomeetGetSharedRecordResp) GetCreationtime() int64 { + if x != nil { + return x.Creationtime + } + return 0 +} + +func (x *EchomeetGetSharedRecordResp) GetSeconds() int32 { + if x != nil { + return x.Seconds + } + return 0 +} + +func (x *EchomeetGetSharedRecordResp) GetAudiourl() string { + if x != nil { + return x.Audiourl + } + return "" +} + +func (x *EchomeetGetSharedRecordResp) GetOriginal() string { + if x != nil { + return x.Original + } + return "" +} + +func (x *EchomeetGetSharedRecordResp) GetTranslate() string { + if x != nil { + return x.Translate + } + return "" +} + +func (x *EchomeetGetSharedRecordResp) GetOverview() string { + if x != nil { + return x.Overview + } + return "" +} + +func (x *EchomeetGetSharedRecordResp) GetSummary() string { + if x != nil { + return x.Summary + } + return "" +} + +func (x *EchomeetGetSharedRecordResp) GetFormlanguage() string { + if x != nil { + return x.Formlanguage + } + return "" +} + +func (x *EchomeetGetSharedRecordResp) GetTolanguage() string { + if x != nil { + return x.Tolanguage + } + return "" +} + +func (x *EchomeetGetSharedRecordResp) GetExpiretime() int64 { + if x != nil { + return x.Expiretime + } + return 0 +} + var File_echomeet_echomeet_msg_proto protoreflect.FileDescriptor const file_echomeet_echomeet_msg_proto_rawDesc = "" + @@ -2270,7 +2560,37 @@ const file_echomeet_echomeet_msg_proto_rawDesc = "" + "\aEndTime\x18\x03 \x01(\x03R\aEndTime\x12\x1c\n" + "\tSpeakerId\x18\x04 \x01(\tR\tSpeakerId\x12\x1c\n" + "\tChannelId\x18\x05 \x01(\x05R\tChannelId\"\x19\n" + - "\x17EchomeetAliCallbackRespB\x06Z\x04.;pbb\x06proto3" + "\x17EchomeetAliCallbackResp\"L\n" + + "\x1aEchomeetCreateShareLinkReq\x12\x0e\n" + + "\x02id\x18\x01 \x01(\x04R\x02id\x12\x1e\n" + + "\n" + + "expiredays\x18\x02 \x01(\x05R\n" + + "expiredays\"U\n" + + "\x1bEchomeetCreateShareLinkResp\x12\x16\n" + + "\x06ticket\x18\x01 \x01(\tR\x06ticket\x12\x1e\n" + + "\n" + + "expiretime\x18\x02 \x01(\x03R\n" + + "expiretime\"4\n" + + "\x1aEchomeetGetSharedRecordReq\x12\x16\n" + + "\x06ticket\x18\x01 \x01(\tR\x06ticket\"\xf1\x02\n" + + "\x1bEchomeetGetSharedRecordResp\x12\x0e\n" + + "\x02id\x18\x01 \x01(\x04R\x02id\x12\x14\n" + + "\x05title\x18\x02 \x01(\tR\x05title\x12\"\n" + + "\fcreationtime\x18\x03 \x01(\x03R\fcreationtime\x12\x18\n" + + "\aseconds\x18\x04 \x01(\x05R\aseconds\x12\x1a\n" + + "\baudiourl\x18\x05 \x01(\tR\baudiourl\x12\x1a\n" + + "\boriginal\x18\x06 \x01(\tR\boriginal\x12\x1c\n" + + "\ttranslate\x18\a \x01(\tR\ttranslate\x12\x1a\n" + + "\boverview\x18\b \x01(\tR\boverview\x12\x18\n" + + "\asummary\x18\t \x01(\tR\asummary\x12\"\n" + + "\fformlanguage\x18\n" + + " \x01(\tR\fformlanguage\x12\x1e\n" + + "\n" + + "tolanguage\x18\v \x01(\tR\n" + + "tolanguage\x12\x1e\n" + + "\n" + + "expiretime\x18\f \x01(\x03R\n" + + "expiretimeB\x06Z\x04.;pbb\x06proto3" var ( file_echomeet_echomeet_msg_proto_rawDescOnce sync.Once @@ -2284,7 +2604,7 @@ func file_echomeet_echomeet_msg_proto_rawDescGZIP() []byte { return file_echomeet_echomeet_msg_proto_rawDescData } -var file_echomeet_echomeet_msg_proto_msgTypes = make([]protoimpl.MessageInfo, 41) +var file_echomeet_echomeet_msg_proto_msgTypes = make([]protoimpl.MessageInfo, 45) var file_echomeet_echomeet_msg_proto_goTypes = []any{ (*EchomeetGetTemplatesReq)(nil), // 0: EchomeetGetTemplatesReq (*EchomeetGetTemplatesResp)(nil), // 1: EchomeetGetTemplatesResp @@ -2327,30 +2647,34 @@ var file_echomeet_echomeet_msg_proto_goTypes = []any{ (*EchomeetAliCallbackResult)(nil), // 38: EchomeetAliCallbackResult (*EchomeetAliSentence)(nil), // 39: EchomeetAliSentence (*EchomeetAliCallbackResp)(nil), // 40: EchomeetAliCallbackResp - (*DBEchoMeetTemplate)(nil), // 41: DBEchoMeetTemplate - (*DBEchoMeetRecord)(nil), // 42: DBEchoMeetRecord - (*ContextStruct)(nil), // 43: ContextStruct - (*RecognitionResult)(nil), // 44: RecognitionResult - (*AudioInfo)(nil), // 45: AudioInfo + (*EchomeetCreateShareLinkReq)(nil), // 41: EchomeetCreateShareLinkReq + (*EchomeetCreateShareLinkResp)(nil), // 42: EchomeetCreateShareLinkResp + (*EchomeetGetSharedRecordReq)(nil), // 43: EchomeetGetSharedRecordReq + (*EchomeetGetSharedRecordResp)(nil), // 44: EchomeetGetSharedRecordResp + (*DBEchoMeetTemplate)(nil), // 45: DBEchoMeetTemplate + (*DBEchoMeetRecord)(nil), // 46: DBEchoMeetRecord + (*ContextStruct)(nil), // 47: ContextStruct + (*RecognitionResult)(nil), // 48: RecognitionResult + (*AudioInfo)(nil), // 49: AudioInfo } var file_echomeet_echomeet_msg_proto_depIdxs = []int32{ - 41, // 0: EchomeetGetTemplatesResp.templates:type_name -> DBEchoMeetTemplate - 41, // 1: EchomeetAddTemplateReq.template:type_name -> DBEchoMeetTemplate - 41, // 2: EchomeetAddTemplateResp.template:type_name -> DBEchoMeetTemplate - 41, // 3: EchomeetUpdateTemplateReq.template:type_name -> DBEchoMeetTemplate - 41, // 4: EchomeetUpdateTemplateResp.template:type_name -> DBEchoMeetTemplate - 42, // 5: EchomeetGetAllRecordsResp.records:type_name -> DBEchoMeetRecord - 42, // 6: EchomeetGetRecordsResp.records:type_name -> DBEchoMeetRecord - 42, // 7: EchomeetGetRecordResp.record:type_name -> DBEchoMeetRecord - 42, // 8: EchomeetPutRecordsReq.records:type_name -> DBEchoMeetRecord - 43, // 9: EchomeetAddRecordReq.original:type_name -> ContextStruct - 42, // 10: EchomeetAddRecordResp.record:type_name -> DBEchoMeetRecord - 42, // 11: EchomeetUpRecordResp.record:type_name -> DBEchoMeetRecord - 44, // 12: EchomeetCallbackReq.result:type_name -> RecognitionResult - 45, // 13: EchomeetCallbackReq.audio_info:type_name -> AudioInfo + 45, // 0: EchomeetGetTemplatesResp.templates:type_name -> DBEchoMeetTemplate + 45, // 1: EchomeetAddTemplateReq.template:type_name -> DBEchoMeetTemplate + 45, // 2: EchomeetAddTemplateResp.template:type_name -> DBEchoMeetTemplate + 45, // 3: EchomeetUpdateTemplateReq.template:type_name -> DBEchoMeetTemplate + 45, // 4: EchomeetUpdateTemplateResp.template:type_name -> DBEchoMeetTemplate + 46, // 5: EchomeetGetAllRecordsResp.records:type_name -> DBEchoMeetRecord + 46, // 6: EchomeetGetRecordsResp.records:type_name -> DBEchoMeetRecord + 46, // 7: EchomeetGetRecordResp.record:type_name -> DBEchoMeetRecord + 46, // 8: EchomeetPutRecordsReq.records:type_name -> DBEchoMeetRecord + 47, // 9: EchomeetAddRecordReq.original:type_name -> ContextStruct + 46, // 10: EchomeetAddRecordResp.record:type_name -> DBEchoMeetRecord + 46, // 11: EchomeetUpRecordResp.record:type_name -> DBEchoMeetRecord + 48, // 12: EchomeetCallbackReq.result:type_name -> RecognitionResult + 49, // 13: EchomeetCallbackReq.audio_info:type_name -> AudioInfo 27, // 14: EchomeetGetSummaryServicesResp.services:type_name -> EchomeetSummaryServiceItem 27, // 15: EchomeetGetCapabilitiesResp.summary_services:type_name -> EchomeetSummaryServiceItem - 42, // 16: EchomeetModifyRecordsResp.record:type_name -> DBEchoMeetRecord + 46, // 16: EchomeetModifyRecordsResp.record:type_name -> DBEchoMeetRecord 38, // 17: EchomeetAliCallbackReq.Result:type_name -> EchomeetAliCallbackResult 39, // 18: EchomeetAliCallbackResult.Sentences:type_name -> EchomeetAliSentence 19, // [19:19] is the sub-list for method output_type @@ -2372,7 +2696,7 @@ func file_echomeet_echomeet_msg_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_echomeet_echomeet_msg_proto_rawDesc), len(file_echomeet_echomeet_msg_proto_rawDesc)), NumEnums: 0, - NumMessages: 41, + NumMessages: 45, NumExtensions: 0, NumServices: 0, }, diff --git a/deploy/app/confs/gateway.yaml.example b/deploy/app/confs/gateway.yaml.example index f547fe54..790d9999 100644 --- a/deploy/app/confs/gateway.yaml.example +++ b/deploy/app/confs/gateway.yaml.example @@ -35,4 +35,10 @@ modules: - public_info - pay_wechatnotify - pay_alipaynotify + # ⚠️ 会议纪要分享落地页(share.ymaikj.com)调它,**打开链接的人本来就没登录**—— + # 少这一行返回 code:18 NoLogin,表现是「分享链接打开一片空白」。 + # 免登录是安全的:它只认服务端签发的分享票据(HMAC 签名、绑定单条记录、有有效期), + # 实现见 modules/echomeet/api_sharelink.go。签发那一端(echomeet_createsharelink)需要登录, + # **不要**把它也加进白名单。 + - echomeet_getsharedrecord Debug: ${LOG_DEBUG:-false} diff --git a/docs/会议纪要分享-落地与上线步骤.md b/docs/会议纪要分享-落地与上线步骤.md new file mode 100644 index 00000000..d6a80d4e --- /dev/null +++ b/docs/会议纪要分享-落地与上线步骤.md @@ -0,0 +1,196 @@ +# 会议纪要分享:落地与上线步骤(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` 仍是空壳,点开打不开。 + 与本次分享链路无关,但它俩是同一类「域名解析了、东西没做」的欠账。