短文支持链接预览卡片:新增 linkmeta 抓取 + 字符集解码
- internal/linkmeta:取正文第一个 http(s) 链接,解析 og: → twitter: → <title>/ description 逐级兜底,解 HTML 实体、相对图片补成绝对地址。按对外抓取设防: 只放行 http(s)、读满 512KB 即停、最多 3 次跳转、整体限时;SSRF 防护做在拨号 那一刻——解析出 IP 后筛掉回环/私网/链路本地/组播/CGNAT 再直连该 IP,堵住 DNS 重绑定窗口 - 字符集转换:按「HTTP 头 charset → <meta charset> → UTF-8」解码,gb2312 归一 成 gbk(超集,按声明解会漏字);认不出的字符名退回原始字节不报错。为此引入 golang.org/x/text(官方包,约 +1MB 二进制)。tidy 顺带把 aws-sdk 三个包从 indirect 修正为直接依赖——storage/r2.go 本来就直接用它们 - posts 加 link_card 列(JSON,沿用 images 的编解码);admin 保存短文时重算: 同链接沿用旧卡片不重复打远端,删链接或抓取失败则清空,长文不参与 - 前端 LinkCard 组件接两套 UI 的时间线与详情页,配色写成 vivid 变量优先、 classic 变量兜底,一份样式两边通用;外链图挂了自动隐藏不留空框 - 卡片用 div[role=link] 而非 <a>(vivid 整条短文包在 RouterLink 里,嵌套 <a> 非法),点击必须 preventDefault——只 stop 挡不住祖先 <a> 的默认激活行为
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"oneblog/internal/model"
|
||||
"oneblog/internal/store"
|
||||
)
|
||||
|
||||
func TestAttachLinkCard(t *testing.T) {
|
||||
old := linkFetch
|
||||
t.Cleanup(func() { linkFetch = old })
|
||||
|
||||
t.Run("长文不动", func(t *testing.T) {
|
||||
called := false
|
||||
linkFetch = func(context.Context, string) (*model.LinkCard, error) {
|
||||
called = true
|
||||
return nil, nil
|
||||
}
|
||||
in := &model.PostInput{Kind: model.KindLong, ContentMd: "看 https://a.cn"}
|
||||
attachLinkCard(context.Background(), in, nil)
|
||||
if in.LinkCard != nil || called {
|
||||
t.Fatalf("长文不该抓取,card=%+v called=%v", in.LinkCard, called)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("没链接则清空", func(t *testing.T) {
|
||||
linkFetch = func(context.Context, string) (*model.LinkCard, error) {
|
||||
t.Fatal("没链接不该抓")
|
||||
return nil, nil
|
||||
}
|
||||
in := &model.PostInput{Kind: model.KindShort, ContentMd: "纯文本一句"}
|
||||
attachLinkCard(context.Background(), in, &model.LinkCard{URL: "https://old.cn", Title: "旧"})
|
||||
if in.LinkCard == nil || !in.LinkCard.Empty() {
|
||||
t.Fatalf("应传空卡片清掉旧的,got %+v", in.LinkCard)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("链接未变则沿用不重抓", func(t *testing.T) {
|
||||
linkFetch = func(context.Context, string) (*model.LinkCard, error) {
|
||||
t.Fatal("同一个链接不该重复抓")
|
||||
return nil, nil
|
||||
}
|
||||
cur := &model.LinkCard{URL: "https://a.cn", Title: "已抓过"}
|
||||
in := &model.PostInput{Kind: model.KindShort, ContentMd: "看 https://a.cn"}
|
||||
attachLinkCard(context.Background(), in, cur)
|
||||
if in.LinkCard != cur {
|
||||
t.Fatalf("应沿用旧卡片,got %+v", in.LinkCard)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("新链接则抓取", func(t *testing.T) {
|
||||
var gotURL string
|
||||
linkFetch = func(_ context.Context, u string) (*model.LinkCard, error) {
|
||||
gotURL = u
|
||||
return &model.LinkCard{URL: u, Title: "新卡片"}, nil
|
||||
}
|
||||
in := &model.PostInput{Kind: model.KindShort, ContentMd: "换成 https://b.cn/x。"}
|
||||
attachLinkCard(context.Background(), in, &model.LinkCard{URL: "https://a.cn", Title: "旧"})
|
||||
if gotURL != "https://b.cn/x" {
|
||||
t.Fatalf("结尾标点没剪掉: %q", gotURL)
|
||||
}
|
||||
if in.LinkCard == nil || in.LinkCard.Title != "新卡片" {
|
||||
t.Fatalf("card=%+v", in.LinkCard)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("抓取失败则清空", func(t *testing.T) {
|
||||
linkFetch = func(context.Context, string) (*model.LinkCard, error) {
|
||||
return nil, errors.New("timeout")
|
||||
}
|
||||
in := &model.PostInput{Kind: model.KindShort, ContentMd: "https://slow.cn"}
|
||||
attachLinkCard(context.Background(), in, &model.LinkCard{URL: "https://a.cn", Title: "旧"})
|
||||
if in.LinkCard == nil || !in.LinkCard.Empty() {
|
||||
t.Fatalf("失败应清空,got %+v", in.LinkCard)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestLinkCardRoundTrip(t *testing.T) {
|
||||
a, _ := newTestAPI(t)
|
||||
card := &model.LinkCard{URL: "https://example.cn/x", Title: "示例标题", Desc: "描述", Site: "example", Image: "https://cdn/i.png"}
|
||||
p, err := a.Store.Create(model.PostInput{Kind: model.KindShort, ContentMd: "看 https://example.cn/x",
|
||||
Status: model.StatusPublished, LinkCard: card})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.LinkCard == nil || p.LinkCard.Title != "示例标题" || p.LinkCard.Image != "https://cdn/i.png" {
|
||||
t.Fatalf("创建后没读回卡片: %+v", p.LinkCard)
|
||||
}
|
||||
// 列表接口也要带上(时间线靠它渲染)
|
||||
page, err := a.Store.List(store.ListOptions{Page: 1, Size: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(page.Items) != 1 || page.Items[0].LinkCard == nil {
|
||||
t.Fatalf("列表未带卡片: %+v", page.Items)
|
||||
}
|
||||
// 空卡片 = 清空
|
||||
if _, err := a.Store.Update(p.ID, model.PostInput{LinkCard: &model.LinkCard{}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := a.Store.Get(p.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.LinkCard != nil {
|
||||
t.Fatalf("应已清空,got %+v", got.LinkCard)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user