Browse Source
链接格式 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) <noreply@anthropic.com>
main
11 changed files with 1290 additions and 31 deletions
@ -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>.<sig> |
||||
|
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 |
||||
|
} |
||||
@ -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) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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/<ticket>,
|
||||
|
// 路径里的段是**数据**不是文件路径。用 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) |
||||
|
} |
||||
@ -0,0 +1,316 @@ |
|||||
|
<!DOCTYPE html> |
||||
|
<html lang="zh-CN"> |
||||
|
<head> |
||||
|
<meta charset="utf-8"> |
||||
|
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"> |
||||
|
<meta name="referrer" content="no-referrer"> |
||||
|
<title>会议纪要</title> |
||||
|
<style> |
||||
|
:root{ |
||||
|
--bg:#f5f6f8; --card:#fff; --fg:#1b1d21; --muted:#6b7280; --line:#e5e7eb; |
||||
|
--accent:#2563eb; --accent-soft:#eff4ff; --danger:#b42318; |
||||
|
} |
||||
|
@media (prefers-color-scheme:dark){ |
||||
|
:root{ |
||||
|
--bg:#101215; --card:#191c21; --fg:#e8eaed; --muted:#9aa1ab; --line:#2a2f37; |
||||
|
--accent:#6ea0ff; --accent-soft:#1b2436; --danger:#ff8a80; |
||||
|
} |
||||
|
} |
||||
|
*{box-sizing:border-box} |
||||
|
html,body{margin:0;padding:0} |
||||
|
body{ |
||||
|
background:var(--bg); color:var(--fg); |
||||
|
font:15px/1.65 -apple-system,BlinkMacSystemFont,"PingFang SC","Hiragino Sans GB","Microsoft YaHei",system-ui,sans-serif; |
||||
|
-webkit-text-size-adjust:100%; |
||||
|
} |
||||
|
.wrap{max-width:760px;margin:0 auto;padding:16px 16px 40px} |
||||
|
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:16px;margin-bottom:12px} |
||||
|
h1{font-size:19px;line-height:1.4;margin:0 0 8px;word-break:break-word} |
||||
|
.meta{color:var(--muted);font-size:13px;display:flex;flex-wrap:wrap;gap:4px 14px} |
||||
|
audio{width:100%;margin-top:14px} |
||||
|
|
||||
|
/* 标签栏:内容少时只显示有内容的那几个 */ |
||||
|
.tabs{display:flex;gap:6px;border-bottom:1px solid var(--line);margin:0 0 14px;overflow-x:auto;-webkit-overflow-scrolling:touch} |
||||
|
.tab{ |
||||
|
flex:0 0 auto;padding:9px 14px;border:0;background:none;color:var(--muted); |
||||
|
font-size:14px;font-family:inherit;cursor:pointer;border-bottom:2px solid transparent; |
||||
|
} |
||||
|
.tab[aria-selected="true"]{color:var(--accent);border-bottom-color:var(--accent);font-weight:600} |
||||
|
.panel[hidden]{display:none} |
||||
|
|
||||
|
/* 转写:每句一行,带说话人与时间戳 */ |
||||
|
.line{display:flex;gap:10px;padding:7px 0;border-bottom:1px solid var(--line)} |
||||
|
.line:last-child{border-bottom:0} |
||||
|
.line .who{flex:0 0 auto;min-width:74px;color:var(--accent);font-size:13px;font-weight:600} |
||||
|
.line .t{flex:0 0 auto;color:var(--muted);font-size:12px;font-variant-numeric:tabular-nums;padding-top:2px} |
||||
|
.line .txt{flex:1 1 auto;word-break:break-word} |
||||
|
|
||||
|
/* Markdown 渲染出来的总结 */ |
||||
|
.md h1,.md h2,.md h3,.md h4{line-height:1.4;margin:18px 0 8px} |
||||
|
.md h1{font-size:18px} .md h2{font-size:17px} .md h3{font-size:15px} .md h4{font-size:14px} |
||||
|
.md>:first-child{margin-top:0} |
||||
|
.md p{margin:8px 0} |
||||
|
.md ul,.md ol{margin:8px 0;padding-left:22px} |
||||
|
.md li{margin:3px 0} |
||||
|
.md code{background:var(--accent-soft);padding:1px 5px;border-radius:4px;font-size:13px} |
||||
|
.md pre{background:var(--accent-soft);padding:12px;border-radius:8px;overflow-x:auto} |
||||
|
.md pre code{background:none;padding:0} |
||||
|
.md blockquote{margin:8px 0;padding:2px 0 2px 12px;border-left:3px solid var(--line);color:var(--muted)} |
||||
|
.md hr{border:0;border-top:1px solid var(--line);margin:16px 0} |
||||
|
.md table{border-collapse:collapse;width:100%;margin:10px 0;font-size:14px;display:block;overflow-x:auto} |
||||
|
.md th,.md td{border:1px solid var(--line);padding:7px 10px;text-align:left} |
||||
|
.md th{background:var(--accent-soft);font-weight:600} |
||||
|
|
||||
|
.state{text-align:center;padding:52px 16px;color:var(--muted)} |
||||
|
.state strong{display:block;color:var(--fg);font-size:16px;margin-bottom:6px} |
||||
|
.state.err strong{color:var(--danger)} |
||||
|
.foot{text-align:center;color:var(--muted);font-size:12px;margin-top:18px;line-height:1.8} |
||||
|
.foot a{color:var(--accent);text-decoration:none} |
||||
|
.spin{width:22px;height:22px;margin:0 auto 12px;border:2px solid var(--line);border-top-color:var(--accent);border-radius:50%;animation:r .8s linear infinite} |
||||
|
@keyframes r{to{transform:rotate(360deg)}} |
||||
|
</style> |
||||
|
</head> |
||||
|
<body> |
||||
|
<div class="wrap" id="root"> |
||||
|
<div class="state"><div class="spin"></div>正在打开…</div> |
||||
|
</div> |
||||
|
|
||||
|
<script> |
||||
|
(function () { |
||||
|
'use strict'; |
||||
|
|
||||
|
// ⚠️ 后端域名白名单。serverHost 是从 URL 路径里读出来的,**不校验就等于**: |
||||
|
// 任何人构造一条 /share/<攻击者域名>/.../<票据> 的链接,就能让打开它的人的浏览器 |
||||
|
// 向攻击者的服务器发请求(顺带把票据送过去)。既是钓鱼跳板也是票据泄漏。 |
||||
|
// 新增环境要在这里加一行,否则那个环境分享出来的链接打不开。 |
||||
|
var ALLOWED_HOSTS = [ |
||||
|
'ym.ymaikj.com', // 正式 |
||||
|
'ym-dev.ymaikj.com' // 阿龙测试 |
||||
|
]; |
||||
|
|
||||
|
var DOWNLOAD_URL = 'https://dl.ymaikj.com'; // 页脚的 App 下载引导 |
||||
|
|
||||
|
var root = document.getElementById('root'); |
||||
|
|
||||
|
function esc(s) { |
||||
|
return String(s == null ? '' : s) |
||||
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') |
||||
|
.replace(/"/g, '"').replace(/'/g, '''); |
||||
|
} |
||||
|
|
||||
|
function show(html) { root.innerHTML = html; } |
||||
|
|
||||
|
function fail(title, detail) { |
||||
|
show('<div class="state err"><strong>' + esc(title) + '</strong>' + esc(detail || '') + '</div>' |
||||
|
+ foot('')); |
||||
|
} |
||||
|
|
||||
|
function foot(extra) { |
||||
|
return '<div class="foot">' + (extra ? extra + '<br>' : '') |
||||
|
+ '由 EAIMAR 生成 · <a href="' + DOWNLOAD_URL + '">下载 App</a></div>'; |
||||
|
} |
||||
|
|
||||
|
// ── 路径解析 ────────────────────────────────────────────── |
||||
|
// /share/{serverHost}/{brand}/{id}/{ticket} |
||||
|
// brand 段是品牌标识,落地页不使用,但保留在路径里以兼容已发出去的链接形状。 |
||||
|
// ⚠️ 票据是 base64url,本身不含 '/',所以按 '/' 切是安全的;真要变格式记得回来改这里。 |
||||
|
function parsePath() { |
||||
|
var seg = location.pathname.split('/').filter(Boolean); // ['share', host, brand, id, ticket] |
||||
|
if (seg.length < 5 || seg[0] !== 'share') return null; |
||||
|
return { host: seg[1], id: seg[3], ticket: seg.slice(4).join('/') }; |
||||
|
} |
||||
|
|
||||
|
// ── 极简 Markdown 渲染(总结是 Markdown,见客户端 _exportMarkdownPdf)──── |
||||
|
// 不引第三方库:这个页面要在国内手机上、可能很差的网络下秒开,为渲染几段 |
||||
|
// 标题和列表去拉一个 CDN 脚本不值得,而且 CDN 被墙时整页就废了。 |
||||
|
// ⚠️ 先 esc 再套正则,顺序不能反 —— 内容来自用户录音与模型输出,反了就是 XSS。 |
||||
|
function md(src) { |
||||
|
var lines = esc(src).replace(/\r\n?/g, '\n').split('\n'); |
||||
|
var out = [], i = 0; |
||||
|
|
||||
|
function inline(t) { |
||||
|
return t |
||||
|
.replace(/`([^`]+)`/g, '<code>$1</code>') |
||||
|
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>') |
||||
|
.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>'); |
||||
|
} |
||||
|
|
||||
|
while (i < lines.length) { |
||||
|
var ln = lines[i]; |
||||
|
|
||||
|
if (/^\s*$/.test(ln)) { i++; continue; } |
||||
|
|
||||
|
// 代码块 |
||||
|
if (/^```/.test(ln)) { |
||||
|
var buf = []; i++; |
||||
|
while (i < lines.length && !/^```/.test(lines[i])) { buf.push(lines[i]); i++; } |
||||
|
i++; |
||||
|
out.push('<pre><code>' + buf.join('\n') + '</code></pre>'); |
||||
|
continue; |
||||
|
} |
||||
|
// 标题 |
||||
|
var h = ln.match(/^(#{1,4})\s+(.*)$/); |
||||
|
if (h) { var lv = h[1].length; out.push('<h' + lv + '>' + inline(h[2]) + '</h' + lv + '>'); i++; continue; } |
||||
|
// 分隔线 |
||||
|
if (/^\s*([-*_])\s*\1\s*\1[\s\-*_]*$/.test(ln)) { out.push('<hr>'); i++; continue; } |
||||
|
// 表格:|a|b| 后跟 |---|---| |
||||
|
if (/^\s*\|/.test(ln) && i + 1 < lines.length && /^\s*\|[\s:|-]+\|\s*$/.test(lines[i + 1])) { |
||||
|
var cells = function (r) { |
||||
|
return r.trim().replace(/^\||\|$/g, '').split('|').map(function (c) { return inline(c.trim()); }); |
||||
|
}; |
||||
|
var head = cells(ln); i += 2; |
||||
|
var body = []; |
||||
|
while (i < lines.length && /^\s*\|/.test(lines[i])) { body.push(cells(lines[i])); i++; } |
||||
|
out.push('<table><thead><tr>' + head.map(function (c) { return '<th>' + c + '</th>'; }).join('') |
||||
|
+ '</tr></thead><tbody>' |
||||
|
+ body.map(function (r) { return '<tr>' + r.map(function (c) { return '<td>' + c + '</td>'; }).join('') + '</tr>'; }).join('') |
||||
|
+ '</tbody></table>'); |
||||
|
continue; |
||||
|
} |
||||
|
// 引用 |
||||
|
// ⚠️ 这里匹配的是 > 不是 >:上面已经整段 esc() 过了,原文的 '>' 此刻是 '>'。 |
||||
|
// 写成 /^\s*>/ 会永远匹配不上,引用块整个退化成普通段落(而且不报错)。 |
||||
|
if (/^\s*>\s?/.test(ln)) { |
||||
|
var q = []; |
||||
|
while (i < lines.length && /^\s*>\s?/.test(lines[i])) { q.push(lines[i].replace(/^\s*>\s?/, '')); i++; } |
||||
|
out.push('<blockquote>' + inline(q.join('<br>')) + '</blockquote>'); |
||||
|
continue; |
||||
|
} |
||||
|
// 列表(有序/无序) |
||||
|
var isOl = /^\s*\d+[.)]\s+/.test(ln); |
||||
|
if (isOl || /^\s*[-*+]\s+/.test(ln)) { |
||||
|
var items = []; |
||||
|
var re = isOl ? /^\s*\d+[.)]\s+/ : /^\s*[-*+]\s+/; |
||||
|
while (i < lines.length && re.test(lines[i])) { items.push(inline(lines[i].replace(re, ''))); i++; } |
||||
|
var tag = isOl ? 'ol' : 'ul'; |
||||
|
out.push('<' + tag + '>' + items.map(function (t) { return '<li>' + t + '</li>'; }).join('') + '</' + tag + '>'); |
||||
|
continue; |
||||
|
} |
||||
|
// 普通段落:连续非空行合成一段 |
||||
|
var p = []; |
||||
|
while (i < lines.length && !/^\s*$/.test(lines[i]) && !/^(#{1,4}\s|```|\s*[-*+]\s|\s*\d+[.)]\s|\s*>|\s*\|)/.test(lines[i])) { |
||||
|
p.push(lines[i]); i++; |
||||
|
} |
||||
|
if (p.length) out.push('<p>' + inline(p.join('<br>')) + '</p>'); |
||||
|
else i++; // 兜底,避免任何情况下的死循环 |
||||
|
} |
||||
|
return out.join(''); |
||||
|
} |
||||
|
|
||||
|
// ── 转写/译文:JSON 数组 [{speaker,content,starttime,endtime}] ──── |
||||
|
// ⚠️ 不能假定它一定是合法 JSON:老记录、导入的外部音频里躺着纯文本。 |
||||
|
// 解析失败就原样当文本显示,总比整页报错强。 |
||||
|
function renderLines(raw) { |
||||
|
if (!raw) return ''; |
||||
|
var arr = null; |
||||
|
try { arr = JSON.parse(raw); } catch (e) { /* 落到纯文本分支 */ } |
||||
|
if (!Array.isArray(arr)) return '<p>' + esc(raw).replace(/\n/g, '<br>') + '</p>'; |
||||
|
return arr.map(function (it) { |
||||
|
var who = it.speaker ? '<span class="who">' + esc(it.speaker) + '</span>' : ''; |
||||
|
var t = (typeof it.starttime === 'number' && it.starttime > 0) |
||||
|
? '<span class="t">' + hms(Math.floor(it.starttime / 1000)) + '</span>' : ''; |
||||
|
return '<div class="line">' + who + t + '<span class="txt">' + esc(it.content || '') + '</span></div>'; |
||||
|
}).join(''); |
||||
|
} |
||||
|
|
||||
|
function hms(sec) { |
||||
|
sec = Math.max(0, sec | 0); |
||||
|
var h = (sec / 3600) | 0, m = ((sec % 3600) / 60) | 0, s = sec % 60; |
||||
|
var p = function (n) { return n < 10 ? '0' + n : '' + n; }; |
||||
|
return (h ? h + ':' : '') + p(m) + ':' + p(s); |
||||
|
} |
||||
|
|
||||
|
function duration(sec) { |
||||
|
if (!sec) return ''; |
||||
|
var m = Math.floor(sec / 60), s = sec % 60; |
||||
|
return m ? (m + ' 分 ' + s + ' 秒') : (s + ' 秒'); |
||||
|
} |
||||
|
|
||||
|
function ymd(unix) { |
||||
|
if (!unix) return ''; |
||||
|
var d = new Date(unix * 1000), p = function (n) { return n < 10 ? '0' + n : n; }; |
||||
|
return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) |
||||
|
+ ' ' + p(d.getHours()) + ':' + p(d.getMinutes()); |
||||
|
} |
||||
|
|
||||
|
// ── 渲染 ─────────────────────────────────────────────────── |
||||
|
function render(r) { |
||||
|
document.title = r.title || '会议纪要'; |
||||
|
|
||||
|
var metas = []; |
||||
|
if (r.creationtime) metas.push('<span>' + esc(ymd(r.creationtime)) + '</span>'); |
||||
|
if (r.seconds) metas.push('<span>时长 ' + esc(duration(r.seconds)) + '</span>'); |
||||
|
|
||||
|
var head = '<div class="card"><h1>' + esc(r.title || '未命名会议') + '</h1>' |
||||
|
+ (metas.length ? '<div class="meta">' + metas.join('') + '</div>' : '') |
||||
|
+ (r.audiourl ? '<audio controls preload="none" src="' + esc(r.audiourl) + '"></audio>' : '') |
||||
|
+ '</div>'; |
||||
|
|
||||
|
// 只放有内容的标签页:空标签点进去一片白,比不显示更让人以为是坏了 |
||||
|
var tabs = []; |
||||
|
if (r.summary) tabs.push({ k: 'sum', n: '总结', h: '<div class="md">' + md(r.summary) + '</div>' }); |
||||
|
else if (r.overview) tabs.push({ k: 'sum', n: '总结', h: '<div class="md">' + md(r.overview) + '</div>' }); |
||||
|
if (r.original) tabs.push({ k: 'org', n: '转写', h: renderLines(r.original) }); |
||||
|
if (r.translate) tabs.push({ k: 'trs', n: '译文', h: renderLines(r.translate) }); |
||||
|
|
||||
|
var body; |
||||
|
if (!tabs.length) { |
||||
|
body = '<div class="card"><div class="state">这条纪要还没有内容<br><span style="font-size:13px">可能仍在转写中,稍后再打开试试</span></div></div>'; |
||||
|
} else { |
||||
|
body = '<div class="card">' |
||||
|
+ (tabs.length > 1 |
||||
|
? '<div class="tabs" role="tablist">' + tabs.map(function (t, i) { |
||||
|
return '<button class="tab" role="tab" data-k="' + t.k + '" aria-selected="' + (i === 0) + '">' + t.n + '</button>'; |
||||
|
}).join('') + '</div>' |
||||
|
: '') |
||||
|
+ tabs.map(function (t, i) { |
||||
|
return '<div class="panel" data-k="' + t.k + '"' + (i === 0 ? '' : ' hidden') + '>' + t.h + '</div>'; |
||||
|
}).join('') |
||||
|
+ '</div>'; |
||||
|
} |
||||
|
|
||||
|
var exp = r.expiretime ? ('本链接于 ' + esc(ymd(r.expiretime)) + ' 失效') : ''; |
||||
|
show(head + body + foot(exp)); |
||||
|
|
||||
|
Array.prototype.forEach.call(document.querySelectorAll('.tab'), function (btn) { |
||||
|
btn.addEventListener('click', function () { |
||||
|
Array.prototype.forEach.call(document.querySelectorAll('.tab'), function (b) { |
||||
|
b.setAttribute('aria-selected', b === btn); |
||||
|
}); |
||||
|
Array.prototype.forEach.call(document.querySelectorAll('.panel'), function (p) { |
||||
|
p.hidden = p.getAttribute('data-k') !== btn.getAttribute('data-k'); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
// ── 主流程 ───────────────────────────────────────────────── |
||||
|
var q = parsePath(); |
||||
|
if (!q) { fail('链接不完整', '请确认复制了完整的分享链接'); return; } |
||||
|
if (ALLOWED_HOSTS.indexOf(q.host) < 0) { fail('链接无效', '来源不在允许列表中'); return; } |
||||
|
|
||||
|
var xhr = new XMLHttpRequest(); |
||||
|
xhr.open('POST', 'https://' + q.host + '/api/home/echomeet_getsharedrecord', true); |
||||
|
xhr.setRequestHeader('Content-Type', 'application/json'); |
||||
|
xhr.timeout = 20000; |
||||
|
xhr.onload = function () { |
||||
|
var res; |
||||
|
try { res = JSON.parse(xhr.responseText); } catch (e) { |
||||
|
fail('打开失败', '服务返回了无法识别的内容'); return; |
||||
|
} |
||||
|
// 业务码非 0 一律按「链接失效」呈现:服务端刻意不区分「票据错」「已过期」 |
||||
|
// 「记录已删」,这里再自作主张分类只会给出不准确的说法。 |
||||
|
// ⚠️ 错误消息的字段名是 msg 不是 message(comm.HttpResult 的 json tag)。 |
||||
|
if (!res || res.code !== 0 || !res.data) { |
||||
|
fail('链接已失效', '分享链接可能已过期,或已被创建者删除'); |
||||
|
return; |
||||
|
} |
||||
|
render(res.data); |
||||
|
}; |
||||
|
xhr.onerror = function () { fail('网络异常', '请检查网络后重试'); }; |
||||
|
xhr.ontimeout = function () { fail('网络超时', '请检查网络后重试'); }; |
||||
|
xhr.send(JSON.stringify({ ticket: q.ticket })); |
||||
|
})(); |
||||
|
</script> |
||||
|
</body> |
||||
|
</html> |
||||
@ -0,0 +1,196 @@ |
|||||
|
# 会议纪要分享:落地与上线步骤(2026-09-23) |
||||
|
|
||||
|
App 里「分享 → 分享链接」发出去的那条链接,之前**从来没有打开过**:域名解析着但没有落地页, |
||||
|
而且链接里带的是用户的完整登录态。这一轮把两件事一起做掉了。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 一、改了什么 |
||||
|
|
||||
|
### 1. 链接里不再是登录 JWT |
||||
|
|
||||
|
| | 之前 | 现在 | |
||||
|
|---|---|---| |
||||
|
| URL 最后一段 | `storage.read("logintoken")`,即 `Authorization` 头里那个 JWT | 服务端签发的**分享票据** | |
||||
|
| 拿到链接能做什么 | 调任何业务接口、看该账号全部纪要、消费算力 | 只能读**这一条**纪要 | |
||||
|
| 失效手段 | 只能改密码 | 默认 7 天自动过期 | |
||||
|
|
||||
|
票据是自包含的 HMAC 签名串,不落库: |
||||
|
|
||||
|
``` |
||||
|
<payload>.<sig> |
||||
|
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/<ticket>`, |
||||
|
路径里的段是**数据**不是文件路径,用 `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, <title>会议纪要</title> |
||||
|
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 <NPM> 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` 仍是空壳,点开打不开。 |
||||
|
与本次分享链路无关,但它俩是同一类「域名解析了、东西没做」的欠账。 |
||||
Loading…
Reference in new issue