短文支持链接预览卡片:新增 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:
Sakurasan
2026-09-28 17:13:20 +08:00
parent 232abf2019
commit 661d157b6c
17 changed files with 1081 additions and 16 deletions
+7
View File
@@ -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:
+45
View File
@@ -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
}
+112
View File
@@ -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)
}
}