短文支持链接预览卡片:新增 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:
@@ -222,6 +222,7 @@ func (a *API) listPosts(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
in.Status = store.NormalizeStatus(in.Status)
|
||||
attachLinkCard(r.Context(), &in, nil)
|
||||
p, err := a.Store.Create(in)
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
@@ -261,6 +262,12 @@ func (a *API) postByID(w http.ResponseWriter, r *http.Request) {
|
||||
if in.Status != "" {
|
||||
in.Status = store.NormalizeStatus(in.Status)
|
||||
}
|
||||
// 多读一次当前行只为拿到已有卡片:链接没变就不必再打远端
|
||||
var curCard *model.LinkCard
|
||||
if cur, err := a.Store.Get(id); err == nil {
|
||||
curCard = cur.LinkCard
|
||||
}
|
||||
attachLinkCard(r.Context(), &in, curCard)
|
||||
p, err := a.Store.Update(id, in)
|
||||
writeOne(w, p, err)
|
||||
case http.MethodDelete:
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"oneblog/internal/linkmeta"
|
||||
"oneblog/internal/model"
|
||||
)
|
||||
|
||||
// linkFetchBudget 是给外链抓取的时间预算:发布不该被一个慢站点拖住,
|
||||
// 超时就当作「这篇没有卡片」。
|
||||
const linkFetchBudget = 2500 * time.Millisecond
|
||||
|
||||
// linkFetch 是要不要抓远端的注入点:默认走带 SSRF 防护的真实抓取,
|
||||
// 测试里换成假实现(真实抓取在测试环境会被自己的防护拦掉)。
|
||||
var linkFetch = linkmeta.Fetch
|
||||
|
||||
// attachLinkCard 按正文重算短文链接卡片,结果写进 in.LinkCard。
|
||||
//
|
||||
// 只处理短文(长文在时间线已有封面媒体位,再加卡片会抢位置);
|
||||
// 链接没变就沿用旧卡片,不重复抓远端;正文里删掉链接、或抓取失败,
|
||||
// 一律传空卡片把旧的清掉——宁可没有卡片,也不要挂着别的地址抓来的旧数据。
|
||||
func attachLinkCard(ctx context.Context, in *model.PostInput, cur *model.LinkCard) {
|
||||
if in.Kind != model.KindShort || in.ContentMd == "" {
|
||||
return
|
||||
}
|
||||
u := linkmeta.FirstURL(in.ContentMd)
|
||||
if u == "" {
|
||||
in.LinkCard = &model.LinkCard{}
|
||||
return
|
||||
}
|
||||
if cur != nil && cur.URL == u {
|
||||
in.LinkCard = cur
|
||||
return
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, linkFetchBudget)
|
||||
defer cancel()
|
||||
card, err := linkFetch(cctx, u)
|
||||
if err != nil {
|
||||
in.LinkCard = &model.LinkCard{}
|
||||
return
|
||||
}
|
||||
in.LinkCard = card
|
||||
}
|
||||
@@ -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