From 661d157b6cd49c90c70188d0e68249be55b3adb3 Mon Sep 17 00:00:00 2001 From: Sakurasan <26715255+Sakurasan@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:13:20 +0800 Subject: [PATCH] =?UTF-8?q?=E7=9F=AD=E6=96=87=E6=94=AF=E6=8C=81=E9=93=BE?= =?UTF-8?q?=E6=8E=A5=E9=A2=84=E8=A7=88=E5=8D=A1=E7=89=87=EF=BC=9A=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20linkmeta=20=E6=8A=93=E5=8F=96=20+=20=E5=AD=97?= =?UTF-8?q?=E7=AC=A6=E9=9B=86=E8=A7=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - internal/linkmeta:取正文第一个 http(s) 链接,解析 og: → twitter: → / 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> 的默认激活行为 --- backend/go.mod | 9 +- backend/go.sum | 2 + backend/internal/admin/api.go | 7 + backend/internal/admin/linkcard.go | 45 +++++ backend/internal/admin/linkcard_test.go | 112 ++++++++++++ backend/internal/linkmeta/charset.go | 107 ++++++++++++ backend/internal/linkmeta/charset_test.go | 122 +++++++++++++ backend/internal/linkmeta/linkmeta.go | 186 ++++++++++++++++++++ backend/internal/linkmeta/linkmeta_test.go | 191 +++++++++++++++++++++ backend/internal/linkmeta/parse.go | 100 +++++++++++ backend/internal/model/model.go | 19 ++ backend/internal/store/store.go | 58 +++++-- frontend/src/components/LinkCard.vue | 123 +++++++++++++ frontend/src/components/PostCard.vue | 4 + frontend/src/ui/yohaku/YohakuArticle.vue | 4 + frontend/src/ui/yohaku/YohakuFeed.vue | 4 + frontend/src/views/PostView.vue | 4 + 17 files changed, 1081 insertions(+), 16 deletions(-) create mode 100644 backend/internal/admin/linkcard.go create mode 100644 backend/internal/admin/linkcard_test.go create mode 100644 backend/internal/linkmeta/charset.go create mode 100644 backend/internal/linkmeta/charset_test.go create mode 100644 backend/internal/linkmeta/linkmeta.go create mode 100644 backend/internal/linkmeta/linkmeta_test.go create mode 100644 backend/internal/linkmeta/parse.go create mode 100644 frontend/src/components/LinkCard.vue diff --git a/backend/go.mod b/backend/go.mod index 56f12da..b79d388 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -3,16 +3,18 @@ module oneblog go 1.24 require ( + github.com/aws/aws-sdk-go-v2 v1.47.1 + github.com/aws/aws-sdk-go-v2/config v1.33.6 + github.com/aws/aws-sdk-go-v2/credentials v1.20.6 + github.com/aws/aws-sdk-go-v2/service/s3 v1.113.4 github.com/lib/pq v1.10.9 github.com/yuin/goldmark v1.7.13 + golang.org/x/text v0.21.0 modernc.org/sqlite v1.39.0 ) require ( - github.com/aws/aws-sdk-go-v2 v1.47.1 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 // indirect - github.com/aws/aws-sdk-go-v2/config v1.33.6 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.20.6 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.1 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.4 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.4 // indirect @@ -21,7 +23,6 @@ require ( github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.5 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.4 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.4 // indirect - github.com/aws/aws-sdk-go-v2/service/s3 v1.113.4 // indirect github.com/aws/aws-sdk-go-v2/service/signin v1.10.1 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.38.1 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.1 // indirect diff --git a/backend/go.sum b/backend/go.sum index a4653a9..b85035a 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -59,6 +59,8 @@ golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM= diff --git a/backend/internal/admin/api.go b/backend/internal/admin/api.go index 079994a..dd8a8e9 100644 --- a/backend/internal/admin/api.go +++ b/backend/internal/admin/api.go @@ -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: diff --git a/backend/internal/admin/linkcard.go b/backend/internal/admin/linkcard.go new file mode 100644 index 0000000..3b657eb --- /dev/null +++ b/backend/internal/admin/linkcard.go @@ -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 +} diff --git a/backend/internal/admin/linkcard_test.go b/backend/internal/admin/linkcard_test.go new file mode 100644 index 0000000..4783e9e --- /dev/null +++ b/backend/internal/admin/linkcard_test.go @@ -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) + } +} diff --git a/backend/internal/linkmeta/charset.go b/backend/internal/linkmeta/charset.go new file mode 100644 index 0000000..b07d3f2 --- /dev/null +++ b/backend/internal/linkmeta/charset.go @@ -0,0 +1,107 @@ +package linkmeta + +import ( + "bytes" + "strings" + + "golang.org/x/text/encoding/htmlindex" + "golang.org/x/text/transform" +) + +// charset.go 负责把响应字节解成 UTF-8 字符串。 +// +// 为什么必须做:老派中文站点(以及不少论坛/院校站)仍在用 GBK/GB2312/Big5, +// 直接当 UTF-8 读会得到一串替换字符,卡片标题就成了乱码。与其显示乱码, +// 不如抓对——这是纯展示层的事,不该让站主去改对方的编码。 + +// decode 按「HTTP 头声明 → 文档里的 <meta charset> → UTF-8」的优先级解码。 +// 已经是 UTF-8/ASCII 时原样返回,不绕转换管线。 +func decode(raw []byte, contentType string) string { + raw = bytes.TrimPrefix(raw, []byte("\ufeff")) // UTF-8 BOM + name := charsetFromHeader(contentType) + if name == "" { + // meta 标签本身是 ASCII(GBK/Big5 都是 ASCII 超集), + // 所以从未解码的原始字节里嗅探是安全的,只看文档开头。 + name = charsetFromMeta(raw) + } + if name == "" || isUTF8(name) { + return string(raw) + } + enc, err := htmlindex.Get(name) + if err != nil { + return string(raw) // 没见过的字符名:按 UTF-8 尽力而为 + } + out, _, err := transform.Bytes(enc.NewDecoder(), raw) + if err != nil && len(out) == 0 { + return string(raw) + } + return string(out) +} + +func charsetFromHeader(ct string) string { + for _, part := range strings.Split(ct, ";") { + kv := strings.SplitN(strings.TrimSpace(part), "=", 2) + if len(kv) == 2 && strings.EqualFold(kv[0], "charset") { + return normalizeCharset(strings.Trim(kv[1], `"'`)) + } + } + return "" +} + +var ( + metaCharset = []byte("charset=") +) + +// charsetFromMeta 在文档开头找 <meta charset="x"> 或 +// <meta http-equiv="Content-Type" content="...; charset=x">。 +func charsetFromMeta(raw []byte) string { + head := raw + if len(head) > 2048 { + head = head[:2048] // 声明一定在前,不必扫全文 + } + lower := bytes.ToLower(head) + i := bytes.Index(lower, metaCharset) + if i < 0 { + return "" + } + rest := head[i+len(metaCharset):] + if len(rest) == 0 { + return "" + } + // 值可能被引号包住;带引号时以配对引号收尾,不带时到引号/空格/;/ > 为止 + // (不闭合的引号也当结束——http-equiv 写法里值是 content="...; charset=gbk", + // 未加引号的 charset 值正好以那个收尾引号终止) + if q := rest[0]; q == '"' || q == '\'' { + rest = rest[1:] + if end := bytes.IndexByte(rest, q); end >= 0 { + rest = rest[:end] + } + } else if end := bytes.IndexAny(rest, `"' ;>`); end >= 0 { + rest = rest[:end] + } + return normalizeCharset(string(rest)) +} + +func normalizeCharset(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + // HTML 标准与 IANA 的常见别名统一成 htmlindex 认得的名字 + switch s { + case "gb2312", "gb_2312", "gb-2312": + return "gbk" // GBK 是 GB2312 的超集,按声明的 GB2312 解会漏字 + case "x-sjis": + return "shift_jis" + case "euckr", "kr": + return "euc-kr" + case "utf8", "utf-8", "ascii", "us-ascii", "": + return s + } + return s +} + +func isUTF8(name string) bool { + switch name { + case "", "utf-8", "utf8", "ascii", "us-ascii": + return true + } + return false +} diff --git a/backend/internal/linkmeta/charset_test.go b/backend/internal/linkmeta/charset_test.go new file mode 100644 index 0000000..f8a9a16 --- /dev/null +++ b/backend/internal/linkmeta/charset_test.go @@ -0,0 +1,122 @@ +package linkmeta + +import ( + "context" + "fmt" + "net" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "golang.org/x/text/encoding/simplifiedchinese" +) + +// gbkPage 把 UTF-8 字符串编成 GBK——老派中文站就是这么发的。 +func gbkPage(t *testing.T, s string) []byte { + t.Helper() + out, err := simplifiedchinese.GBK.NewEncoder().Bytes([]byte(s)) + if err != nil { + t.Fatal(err) + } + return out +} + +func TestDecodeGBKMetaCharset(t *testing.T) { + // 只在 <meta> 里声明,HTTP 头不带 charset——这是最常见的写法 + doc := `<html><head><meta charset="gbk"><meta property="og:title" content="围棋职业棋士的直播间"></head></html>` + raw := gbkPage(t, doc) + got := parse(decode(raw, "text/html"), mustBase(t, "https://example.com/")) + if got.Title != "围棋职业棋士的直播间" { + t.Fatalf("GBK 未正确解码: %q", got.Title) + } +} + +func TestDecodeHTTPHeaderWins(t *testing.T) { + doc := `<html><head><title>标题测试` + raw := gbkPage(t, doc) + got := parse(decode(raw, `text/html; charset=GB2312`), mustBase(t, "https://example.com/")) + if got.Title != "标题测试" { + t.Fatalf("HTTP 头声明的字符集未生效: %q", got.Title) + } +} + +func TestDecodeHTTPEquivMeta(t *testing.T) { + doc := `中文标题` + got := parse(decode(gbkPage(t, doc), "text/html"), mustBase(t, "https://example.com/")) + if got.Title != "中文标题" { + t.Fatalf("http-equiv 形式未识别: %q", got.Title) + } +} + +func TestDecodePassthroughAndUnknown(t *testing.T) { + utf8 := `已经是 UTF-8` + if got := decode([]byte(utf8), "text/html; charset=utf-8"); got != utf8 { + t.Error("UTF-8 不该走转换管线") + } + if got := decode([]byte(utf8), "text/html; charset=bogus-9999"); got != utf8 { + t.Error("认不出的字符集应退回原始字节而不是报错") + } + if got := decode([]byte("\ufeff"+utf8), "text/html"); got != utf8 { + t.Error("BOM 应被剥掉") + } +} + +func TestCharsetFromHeader(t *testing.T) { + cases := map[string]string{ + `text/html; charset=UTF-8`: "utf-8", + `text/html;charset="gbk"`: "gbk", + `text/html; charset=GB2312`: "gbk", // 别名归一:GBK 是超集 + `text/html`: "", + } + for in, want := range cases { + if got := charsetFromHeader(in); got != want { + t.Errorf("charsetFromHeader(%q) = %q, want %q", in, got, want) + } + } +} + +func TestCharsetFromMeta(t *testing.T) { + cases := map[string]string{ + ``: "big5", + ``: "gbk", + ``: "shift_jis", + `没有声明`: "", + } + for in, want := range cases { + if got := charsetFromMeta([]byte(in)); got != want { + t.Errorf("charsetFromMeta(%q) = %q, want %q", in, got, want) + } + } +} + +// 端到端:真发一个 GBK 页面(HTTP 头故意不写 charset),确认整条链路出正确卡片。 +func TestFetcherDecodesGBKEndToEnd(t *testing.T) { + doc := `` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") // 不声明 charset,逼它去嗅 meta + fmt.Fprint(w, string(gbkPage(t, doc))) + })) + defer srv.Close() + + f := &Fetcher{Dial: func(ctx context.Context, network, addr string) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, network, addr) + }} + c, err := f.Fetch(context.Background(), srv.URL) + if err != nil { + t.Fatal(err) + } + if c.Title != "鱼妹妹下棋" || c.Site != "某中文站" { + t.Fatalf("card=%+v", c) + } +} + +func mustBase(t *testing.T, s string) *url.URL { + t.Helper() + u, err := url.Parse(s) + if err != nil { + t.Fatal(err) + } + return u +} diff --git a/backend/internal/linkmeta/linkmeta.go b/backend/internal/linkmeta/linkmeta.go new file mode 100644 index 0000000..02a995f --- /dev/null +++ b/backend/internal/linkmeta/linkmeta.go @@ -0,0 +1,186 @@ +// Package linkmeta 从正文里的链接抓一份「链接卡片」素材:标题、描述、站点名、封面图。 +// +// 抓取由站主写作时触发(不是读者请求触发),但目标地址仍是任意公网 URL, +// 所以按对外抓取的标准对待:只放行 http/https、限时限量限跳转, +// 并在拨号那一刻解析并拒绝内网地址——防的是「服务器自己打自己」这类 SSRF, +// 以及 DNS 先返回公网 IP、拨号时换成内网 IP 的重绑定把戏。 +package linkmeta + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "regexp" + "strings" + "time" + + "oneblog/internal/model" +) + +const ( + // maxBody 只读文档开头就够拿到 ,多一个字节都不多读。 + maxBody = 512 << 10 + // maxRedirects 跳转链上限。 + maxRedirects = 3 + userAgent = "Mozilla/5.0 (compatible; ONEBot/1.0; +link-preview)" +) + +// Card 是抓取结果的数据形状;定义在 model 里(要落库、要给前端), +// 这里用别名保持本包的写法。 +type Card = model.LinkCard + +var ( + urlRe = regexp.MustCompile(`https?://[^\s<>"'\)\]]+`) + trailingCut = ".,;:!?、。)]》」》" +) + +// FirstURL 取正文里第一个 http(s) 链接(Markdown 原文,含代码块里的也算, +// 站主自己不会在代码块里贴想展示的链接)。没有则空串。 +func FirstURL(md string) string { + m := urlRe.FindString(md) + if m == "" { + return "" + } + return strings.TrimRight(m, trailingCut) +} + +// Fetcher 抓取器。Dial 为 nil 时用带 SSRF 防护的默认拨号器; +// 测试里注入普通拨号器才能打到 httptest 的回环地址(默认会被拦掉)。 +type Fetcher struct { + Dial func(ctx context.Context, network, addr string) (net.Conn, error) + Timeout time.Duration // 0 = 默认 5s +} + +// Fetch 用默认安全拨号器抓 rawURL 的元信息。ctx 控制整体时限。 +func Fetch(ctx context.Context, rawURL string) (*Card, error) { + return (&Fetcher{}).Fetch(ctx, rawURL) +} + +// Fetch 抓 rawURL 的元信息。 +func (f *Fetcher) Fetch(ctx context.Context, rawURL string) (*Card, error) { + u, err := url.Parse(strings.TrimSpace(rawURL)) + if err != nil { + return nil, fmt.Errorf("linkmeta: bad url: %w", err) + } + if u.Scheme != "http" && u.Scheme != "https" { + return nil, fmt.Errorf("linkmeta: scheme %q not allowed", u.Scheme) + } + if u.Host == "" { + return nil, errors.New("linkmeta: empty host") + } + + dial := f.Dial + if dial == nil { + dial = safeDial + } + timeout := f.Timeout + if timeout <= 0 { + timeout = 5 * time.Second + } + client := &http.Client{ + Transport: &http.Transport{ + DialContext: dial, + TLSHandshakeTimeout: 3 * time.Second, + // 每个跳转目标都过一遍 dial(transport 会复用),无需额外校验 + ForceAttemptHTTP2: false, + }, + Timeout: timeout, + CheckRedirect: limitRedirects, + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", userAgent) + req.Header.Set("Accept", "text/html,application/xhtml+xml;q=0.9,*/*;q=0.5") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("linkmeta: fetch: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + return nil, fmt.Errorf("linkmeta: status %d", resp.StatusCode) + } + if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "text/html") { + return nil, fmt.Errorf("linkmeta: not html (%q)", ct) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, maxBody)) + if err != nil { + return nil, fmt.Errorf("linkmeta: read: %w", err) + } + final := resp.Request.URL // 跟完跳转后的最终地址 + card := parse(decode(body, resp.Header.Get("Content-Type")), final) + if card.Empty() { + return nil, errors.New("linkmeta: no usable metadata") + } + return card, nil +} + +func limitRedirects(req *http.Request, via []*http.Request) error { + if len(via) > maxRedirects { + return fmt.Errorf("linkmeta: too many redirects") + } + if req.URL.Scheme != "http" && req.URL.Scheme != "https" { + return fmt.Errorf("linkmeta: redirect to %q", req.URL.Scheme) + } + return nil +} + +// safeDial 解析域名后逐个筛掉内网地址,再直接拨那个 IP: +// 校验和连接之间不再重新解析,DNS 重绑定就没有窗口。 +func safeDial(ctx context.Context, network, addr string) (net.Conn, error) { + host, port, err := net.SplitHostPort(addr) + if err != nil { + return nil, err + } + var d net.Dialer + if ip := net.ParseIP(host); ip != nil { + if blocked(ip) { + return nil, fmt.Errorf("linkmeta: %s is not a public address", ip) + } + return d.DialContext(ctx, network, addr) + } + ips, err := d.Resolver.LookupIPAddr(ctx, host) + if err != nil { + return nil, err + } + var lastErr error + for _, ia := range ips { + if blocked(ia.IP) { + continue + } + conn, err := d.DialContext(ctx, network, net.JoinHostPort(ia.IP.String(), port)) + if err == nil { + return conn, nil + } + lastErr = err + } + if lastErr != nil { + return nil, lastErr + } + return nil, fmt.Errorf("linkmeta: no public address for %s", host) +} + +// blocked 报告 IP 是否属于不该由本站去连的地址段。 +func blocked(ip net.IP) bool { + if ip == nil || ip.IsUnspecified() || ip.IsLoopback() || ip.IsPrivate() || + ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsInterfaceLocalMulticast() || + ip.IsMulticast() { + return true + } + // 运营商级 NAT 与 6to4 前缀:IsPrivate 不覆盖,但同样不该出现在公网抓取里 + if ip4 := ip.To4(); ip4 != nil { + return ip4[0] == 100 && ip4[1] >= 64 && ip4[1] <= 127 // 100.64.0.0/10 + } + if ip.To16() != nil { + return ip[0] == 0x20 || ip[0] == 0x3f // 2001::/32 Teredo、3ffe::/16 等保留段 + } + return false +} diff --git a/backend/internal/linkmeta/linkmeta_test.go b/backend/internal/linkmeta/linkmeta_test.go new file mode 100644 index 0000000..f83687c --- /dev/null +++ b/backend/internal/linkmeta/linkmeta_test.go @@ -0,0 +1,191 @@ +package linkmeta + +import ( + "context" + "fmt" + "net" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" +) + +func TestFirstURL(t *testing.T) { + cases := map[string]string{ + "看看 https://example.com/a?x=1 这个": "https://example.com/a?x=1", + "[Go 语言](https://go.dev/doc) 官方文档": "https://go.dev/doc", + "结尾标点要剪掉 https://a.cn/page。": "https://a.cn/page", + "没有链接就是空": "", + "两个 https://first.cn https://second.cn": "https://first.cn", + "裸 www.example.com 不算": "", + } + for in, want := range cases { + if got := FirstURL(in); got != want { + t.Errorf("FirstURL(%q) = %q, want %q", in, got, want) + } + } +} + +func TestParsePriority(t *testing.T) { + doc := ` + 兜底标题 + + + + + +

正文里的 标签 不该被当成标题

` + base, _ := url.Parse("https://example.com/post/1") + c := parse(doc, base) + + if c.Title != "OG 标题" { + t.Errorf("title=%q 应优先 og:title", c.Title) + } + if c.Desc != "OG 描述" { + t.Errorf("desc=%q", c.Desc) + } + if c.Site != "示例站" { + t.Errorf("site=%q", c.Site) + } + if c.Image != "https://example.com/pics/a.png" { + t.Errorf("相对图片未补全: %q", c.Image) + } +} + +func TestParseFallsBackToTitleTag(t *testing.T) { + base, _ := url.Parse("https://example.com/") + c := parse(` 只有 + 标题 `, base) + if c.Title != "只有 标题" { + t.Errorf("title=%q", c.Title) + } + if c.Site != "example.com" { + t.Errorf("site=%q 应退回主机名", c.Site) + } +} + +func TestParseUnescapesEntities(t *testing.T) { + base, _ := url.Parse("https://example.com/") + c := parse(``, base) + if c.Title != `Tom & Jerry "quoted"` { + t.Errorf("title=%q", c.Title) + } +} + +func TestBlockedAddresses(t *testing.T) { + want := map[string]bool{ + "127.0.0.1": true, + "10.0.3.5": true, + "172.16.0.1": true, + "192.168.1.1": true, + "169.254.169.254": true, // 云元数据 + "100.64.0.1": true, // CGNAT + "::1": true, + "fe80::1": true, + "fc00::1": true, // ULA + "93.184.216.34": false, + "2606:2800:220:1:248:1893:25c8:1946": false, + } + for s, wantBlocked := range want { + ip := net.ParseIP(s) + if ip == nil { + t.Fatalf("bad test ip %q", s) + } + if got := blocked(ip); got != wantBlocked { + t.Errorf("blocked(%s) = %v, want %v", s, got, wantBlocked) + } + } +} + +// 默认拨号器必须拒掉回环地址——httptest 的服务就在 127.0.0.1, +// 这条同时验证了「防护生效」和「测试用的注入通道确实是必要的」。 +func TestDefaultFetchRejectsLoopback(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `x`) + })) + defer srv.Close() + + _, err := Fetch(context.Background(), srv.URL) + if err == nil { + t.Fatal("默认 Fetcher 竟然后到了回环地址,SSRF 防护形同虚设") + } + if !strings.Contains(err.Error(), "not a public address") && !strings.Contains(err.Error(), "no public address") { + t.Fatalf("err=%v,应因内网地址被拒", err) + } +} + +func TestFetcherParsesRealPage(t *testing.T) { + var gotUA, gotAccept string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotUA, gotAccept = r.Header.Get("User-Agent"), r.Header.Get("Accept") + if r.URL.Path == "/nope" { + w.WriteHeader(http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, ``) + })) + defer srv.Close() + + f := &Fetcher{Dial: func(ctx context.Context, network, addr string) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, network, addr) + }, Timeout: 2 * time.Second} + + c, err := f.Fetch(context.Background(), srv.URL+"/page") + if err != nil { + t.Fatal(err) + } + if c.Title != "标题" || c.Image != "https://cdn.example/i.png" { + t.Fatalf("card=%+v", c) + } + if !strings.HasPrefix(c.URL, srv.URL) { + t.Errorf("url=%q", c.URL) + } + if gotUA != userAgent { + t.Errorf("ua=%q", gotUA) + } + if !strings.Contains(gotAccept, "text/html") { + t.Errorf("accept=%q", gotAccept) + } + if _, err := f.Fetch(context.Background(), srv.URL+"/nope"); err == nil { + t.Error("404 应报错") + } +} + +func TestFetchRejectsNonHTMLAndBadScheme(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/octet-stream") + w.Write([]byte("binary")) + })) + defer srv.Close() + + f := &Fetcher{Dial: func(ctx context.Context, network, addr string) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, network, addr) + }} + if _, err := f.Fetch(context.Background(), srv.URL); err == nil { + t.Error("非 HTML 应拒绝") + } + if _, err := f.Fetch(context.Background(), "ftp://example.com/x"); err == nil { + t.Error("非 http(s) 协议应拒绝") + } + if _, err := f.Fetch(context.Background(), "not a url"); err == nil { + t.Error("坏 URL 应报错") + } +} + +func TestCardEmpty(t *testing.T) { + if !(&Card{URL: "https://x.cn"}).Empty() { + t.Error("只有 URL 不算有内容") + } + if (&Card{URL: "https://x.cn", Title: "t"}).Empty() { + t.Error("有标题不该判空") + } + var nilCard *Card + if !nilCard.Empty() { + t.Error("nil 应判空") + } +} diff --git a/backend/internal/linkmeta/parse.go b/backend/internal/linkmeta/parse.go new file mode 100644 index 0000000..fde1cb0 --- /dev/null +++ b/backend/internal/linkmeta/parse.go @@ -0,0 +1,100 @@ +package linkmeta + +import ( + "html" + "net/url" + "regexp" + "strings" +) + +var ( + headRe = regexp.MustCompile(`(?is)]*>(.*?)`) + metaRe = regexp.MustCompile(`(?is)]*?>`) + attrRe = regexp.MustCompile(`(?is)\b(property|name|http-equiv|content)\s*=\s*("([^"]*)"|'([^']*)'|([^\s>"']+))`) + titleRe = regexp.MustCompile(`(?is)]*>(.*?)`) + tagRe = regexp.MustCompile(`(?s)<[^>]*>`) + wsRe = regexp.MustCompile(`\s+`) +) + +// parse 从 HTML 里挑出卡片字段。优先 Open Graph,其次 Twitter Card,最后 +// 退到 与 description——绝大多数站点至少满足其中一个。 +func parse(doc string, base *url.URL) *Card { + c := &Card{URL: base.String(), Site: base.Hostname()} + + head := doc + if m := headRe.FindStringSubmatch(doc); m != nil { + head = m[1] + } else if len(head) > 64<<10 { + head = head[:64<<10] // 没有闭合 <head> 时也别整篇扫 + } + + // 同一个 key 出现多次时先出现的赢(后面的往往是重复声明) + meta := map[string]string{} + for _, tag := range metaRe.FindAllString(head, -1) { + key, val := metaTag(tag) + if key == "" || val == "" { + continue + } + if _, seen := meta[key]; !seen { + meta[key] = val + } + } + + c.Title = first(meta["og:title"], meta["twitter:title"]) + if c.Title == "" { + if m := titleRe.FindStringSubmatch(head); m != nil { + c.Title = clean(m[1]) + } + } + c.Desc = first(meta["og:description"], meta["twitter:description"], meta["description"]) + if site := meta["og:site_name"]; site != "" { + c.Site = site + } + if img := first(meta["og:image"], meta["twitter:image"], meta["twitter:image:src"]); img != "" { + c.Image = resolve(img, base) + } + return c +} + +// metaTag 从单个 <meta> 标签里取 (key, content),属性顺序不限。 +func metaTag(tag string) (string, string) { + var key, val string + for _, a := range attrRe.FindAllStringSubmatch(tag, -1) { + v := first(a[3], a[4], a[5]) + switch strings.ToLower(a[1]) { + case "property", "name", "http-equiv": + key = strings.ToLower(v) + case "content": + val = v + } + } + return key, clean(val) +} + +// clean 去标签、解实体、压空白:抓来的文本可能带内联标签或连续换行。 +func clean(s string) string { + s = html.UnescapeString(tagRe.ReplaceAllString(s, " ")) + return strings.TrimSpace(wsRe.ReplaceAllString(s, " ")) +} + +// resolve 把相对的图片地址补成绝对 URL;解析不了就丢掉这个字段。 +func resolve(ref string, base *url.URL) string { + ref = strings.TrimSpace(ref) + if ref == "" || strings.HasPrefix(ref, "data:") { + return "" + } + u, err := url.Parse(ref) + if err != nil { + return "" + } + return base.ResolveReference(u).String() +} + +func first(vals ...string) string { + for _, v := range vals { + if v != "" { + return v + } + } + return "" +} diff --git a/backend/internal/model/model.go b/backend/internal/model/model.go index 1e7726b..2437bbf 100644 --- a/backend/internal/model/model.go +++ b/backend/internal/model/model.go @@ -32,6 +32,23 @@ type Post struct { // Images 是短文配图(快发盒上传,≤4 张,Twitter 式网格展示), // 以 JSON 数组存在 posts.images 列里,正文 content_md 不含它们。 Images []string `json:"images"` + // LinkCard 是正文里第一个外链的预览卡片(发布时抓一次,存 posts.link_card)。 + // nil = 没有链接或抓取失败,前台不渲染卡片。 + LinkCard *LinkCard `json:"link_card,omitempty"` +} + +// LinkCard 是链接预览卡片:由 internal/linkmeta 抓取,以 JSON 存在 posts 表里。 +type LinkCard struct { + URL string `json:"url"` + Title string `json:"title,omitempty"` + Desc string `json:"desc,omitempty"` + Site string `json:"site,omitempty"` + Image string `json:"image,omitempty"` +} + +// Empty 报告卡片有没有可展示的字段(只有 URL 不算)。 +func (c *LinkCard) Empty() bool { + return c == nil || (c.Title == "" && c.Desc == "" && c.Image == "") } type PostInput struct { @@ -47,6 +64,8 @@ type PostInput struct { ReadingMinutes *int `json:"reading_minutes"` // Images 是短文配图 URL(快发盒上传),nil = 不修改、空数组 = 清空 Images []string `json:"images"` + // LinkCard 由服务端在保存时按正文内容重算(站主不必发这个字段) + LinkCard *LinkCard `json:"link_card,omitempty"` } type Tag struct { diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go index d893003..575cce5 100644 --- a/backend/internal/store/store.go +++ b/backend/internal/store/store.go @@ -135,6 +135,8 @@ func (s *Store) migrate() error { `ALTER TABLE tags ADD COLUMN color TEXT NOT NULL DEFAULT ''`, // 短文配图(快发盒上传,Twitter 式网格展示),JSON 数组 `ALTER TABLE posts ADD COLUMN images TEXT NOT NULL DEFAULT '[]'`, + // 正文首个外链的预览卡片(og 标题/描述/封面),JSON 对象或空串 + `ALTER TABLE posts ADD COLUMN link_card TEXT NOT NULL DEFAULT ''`, } for _, q := range columnAdds { if _, err := s.db.Exec(s.db.Q(q)); err != nil && !strings.Contains(err.Error(), "already exists") && @@ -477,14 +479,14 @@ func sanitizeOrder(o string) string { } const postCols = `id, kind, title, slug, summary, cover_url, content_md, content_html, status, - published_at, created_at, updated_at, reading_minutes, LENGTH(content_md), images` + published_at, created_at, updated_at, reading_minutes, LENGTH(content_md), images, link_card` // listCols 用于列表/时间线:不传 content_md(前端不用), // 长文 content_html 只截 600 字符供无摘要时提取纯文本,短文保留全文渲染。 const listCols = `id, kind, title, slug, summary, cover_url, '' AS content_md, CASE WHEN kind = 'short' THEN content_html ELSE substr(content_html, 1, 600) END AS content_html, - status, published_at, created_at, updated_at, reading_minutes, LENGTH(content_md), images` + status, published_at, created_at, updated_at, reading_minutes, LENGTH(content_md), images, link_card` // images 列的 JSON 编解码(列存 '[]',Go 侧 []string;坏数据静默为空)。 func decodeImages(s string) []string { @@ -509,14 +511,39 @@ func encodeImages(imgs []string) string { return string(b) } +// link_card 列的 JSON 编解码:空串/坏数据一律当作「没有卡片」。 +func decodeLinkCard(s string) *model.LinkCard { + s = strings.TrimSpace(s) + if s == "" { + return nil + } + var c model.LinkCard + if err := json.Unmarshal([]byte(s), &c); err != nil || c.Empty() { + return nil + } + return &c +} + +func encodeLinkCard(c *model.LinkCard) string { + if c.Empty() { + return "" + } + b, err := json.Marshal(c) + if err != nil { + return "" + } + return string(b) +} + func scanPost(rows interface{ Scan(...any) error }) (model.Post, error) { var p model.Post - var imgs string + var imgs, card string err := rows.Scan(&p.ID, &p.Kind, &p.Title, &p.Slug, &p.Summary, &p.CoverURL, &p.ContentMd, &p.ContentHTML, &p.Status, &p.PublishedAt, &p.CreatedAt, &p.UpdatedAt, - &p.ReadingMinutes, &p.ContentLen, &imgs) + &p.ReadingMinutes, &p.ContentLen, &imgs, &card) p.Tags = []string{} p.Images = decodeImages(imgs) + p.LinkCard = decodeLinkCard(card) return p, err } @@ -689,10 +716,10 @@ func (s *Store) GetBySlug(slug string) (model.Post, error) { } func scanPostInto(row *sql.Row, p *model.Post) error { - var imgs string + var imgs, card string err := row.Scan(&p.ID, &p.Kind, &p.Title, &p.Slug, &p.Summary, &p.CoverURL, &p.ContentMd, &p.ContentHTML, &p.Status, &p.PublishedAt, &p.CreatedAt, &p.UpdatedAt, - &p.ReadingMinutes, &p.ContentLen, &imgs) + &p.ReadingMinutes, &p.ContentLen, &imgs, &card) if err == sql.ErrNoRows { return ErrNotFound } @@ -701,6 +728,7 @@ func scanPostInto(row *sql.Row, p *model.Post) error { } p.Tags = []string{} p.Images = decodeImages(imgs) + p.LinkCard = decodeLinkCard(card) return nil } @@ -945,17 +973,19 @@ func (s *Store) Create(in model.PostInput) (model.Post, error) { var id int64 q := s.db.Q(`INSERT INTO posts (kind,title,slug,summary,cover_url,content_md,content_html,status, - published_at,created_at,updated_at,reading_minutes,images) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`) + published_at,created_at,updated_at,reading_minutes,images,link_card) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`) if s.db.Dialect == db.Postgres { err := s.db.QueryRow(q, p.Kind, p.Title, p.Slug, p.Summary, p.CoverURL, p.ContentMd, p.ContentHTML, - p.Status, p.PublishedAt, p.CreatedAt, p.UpdatedAt, p.ReadingMinutes, encodeImages(in.Images)).Scan(&id) + p.Status, p.PublishedAt, p.CreatedAt, p.UpdatedAt, p.ReadingMinutes, encodeImages(in.Images), + encodeLinkCard(in.LinkCard)).Scan(&id) if err != nil { return p, err } } else { res, err := s.db.Exec(q, p.Kind, p.Title, p.Slug, p.Summary, p.CoverURL, p.ContentMd, p.ContentHTML, - p.Status, p.PublishedAt, p.CreatedAt, p.UpdatedAt, p.ReadingMinutes, encodeImages(in.Images)) + p.Status, p.PublishedAt, p.CreatedAt, p.UpdatedAt, p.ReadingMinutes, encodeImages(in.Images), + encodeLinkCard(in.LinkCard)) if err != nil { return p, err } @@ -1007,6 +1037,10 @@ func (s *Store) Update(id int64, in model.PostInput) (model.Post, error) { if in.Images != nil { p.Images = in.Images } + // LinkCard 由 admin 层按正文重算后传入;空卡片(含只有 URL)= 清空 + if in.LinkCard != nil { + p.LinkCard = in.LinkCard + } p.UpdatedAt = now() if in.ReadingMinutes != nil && *in.ReadingMinutes > 0 { p.ReadingMinutes = *in.ReadingMinutes @@ -1015,9 +1049,9 @@ func (s *Store) Update(id int64, in model.PostInput) (model.Post, error) { } if _, err := s.db.Exec(s.db.Q(`UPDATE posts SET kind=?,title=?,slug=?,summary=?,cover_url=?,content_md=?, - content_html=?,status=?,published_at=?,updated_at=?,reading_minutes=?,images=? WHERE id=?`), + content_html=?,status=?,published_at=?,updated_at=?,reading_minutes=?,images=?,link_card=? WHERE id=?`), p.Kind, p.Title, p.Slug, p.Summary, p.CoverURL, p.ContentMd, p.ContentHTML, p.Status, - p.PublishedAt, p.UpdatedAt, p.ReadingMinutes, encodeImages(p.Images), id); err != nil { + p.PublishedAt, p.UpdatedAt, p.ReadingMinutes, encodeImages(p.Images), encodeLinkCard(p.LinkCard), id); err != nil { return p, err } if in.Tags != nil { diff --git a/frontend/src/components/LinkCard.vue b/frontend/src/components/LinkCard.vue new file mode 100644 index 0000000..2180a25 --- /dev/null +++ b/frontend/src/components/LinkCard.vue @@ -0,0 +1,123 @@ +<script setup> +import { ref } from 'vue' + +// 链接预览卡片:正文里第一个外链在发布时由后端抓好(posts.link_card), +// 这里只负责展示。没有可展示内容时父组件根本不会渲染它。 +// +// 用 div[role=link] 而不是 <a>:vivid 的整条短文包在 RouterLink(一个 <a>)里, +// 再嵌一个 <a> 是非法 HTML。也因此点击必须 preventDefault—— +// 只 stop 的话祖先 <a> 的默认激活行为照样会把点击变成跳转。 +const props = defineProps({ + card: { type: Object, required: true } +}) + +const imgBroken = ref(false) + +function open() { + window.open(props.card.url, '_blank', 'noopener,noreferrer') +} +</script> + +<template> + <div + class="link-card" + role="link" + tabindex="0" + :title="card.url" + @click.prevent.stop="open" + @keydown.enter.prevent="open" + > + <div class="lc-text"> + <span v-if="card.site" class="lc-site">{{ card.site }}</span> + <span v-if="card.title" class="lc-title">{{ card.title }}</span> + <span v-if="card.desc" class="lc-desc">{{ card.desc }}</span> + </div> + <img + v-if="card.image && !imgBroken" + class="lc-img" + :src="card.image" + alt="" + loading="lazy" + decoding="async" + @error="imgBroken = true" + /> + </div> +</template> + +<style scoped> +/* 颜色一律写成「vivid 变量优先、classic 变量兜底」:同一份组件在两套 UI 里 + 各归各的调色板,不必再维护第二份样式表。 */ +.link-card { + display: flex; + align-items: stretch; + gap: 12px; + margin-top: 10px; + padding: 10px 12px; + border: 1px solid var(--v-line, var(--line-soft)); + border-radius: var(--v-radius-sm, 10px); + background: var(--v-surface, var(--card)); + cursor: pointer; + transition: border-color 0.15s ease, background 0.15s ease; +} + +.link-card:hover { + border-color: var(--v-line-strong, var(--accent-line)); +} + +.link-card:focus-visible { + outline: 2px solid var(--v-accent, var(--accent)); + outline-offset: 2px; +} + +.lc-text { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; + flex: 1; +} + +.lc-site { + font-size: 11.5px; + letter-spacing: 0.04em; + text-transform: lowercase; + color: var(--v-muted, var(--faint)); +} + +.lc-title { + font-size: 15px; + line-height: 1.45; + font-weight: 600; + color: var(--v-text, var(--ink)); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.lc-desc { + font-size: 13px; + line-height: 1.6; + color: var(--v-text-soft, var(--muted)); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.lc-img { + flex: 0 0 auto; + width: 84px; + height: 84px; + object-fit: cover; + border-radius: var(--v-radius-sm, 8px); + background: var(--v-surface-2, var(--paper-sunken)); +} + +@media (max-width: 520px) { + .lc-img { + width: 64px; + height: 64px; + } +} +</style> diff --git a/frontend/src/components/PostCard.vue b/frontend/src/components/PostCard.vue index 29eb2be..bb9cc18 100644 --- a/frontend/src/components/PostCard.vue +++ b/frontend/src/components/PostCard.vue @@ -4,6 +4,7 @@ import { useRouter } from 'vue-router' import { site } from '../site' import { openLightbox } from '../lightbox' import { relativeDate, stripTags, minutesLabel, sanitizeHtml, thumbURL, thumbifyHtml } from '../utils' +import LinkCard from './LinkCard.vue' const router = useRouter() @@ -83,6 +84,9 @@ const gridW = computed(() => (props.post.images && props.post.images.length === /> </div> + <!-- 外链预览卡片:正文里第一个链接,发布时后端已抓好 --> + <LinkCard v-if="isShort && post.link_card" :card="post.link_card" /> + <div class="meta"> <RouterLink :to="`/post/${post.slug}`" class="read" :title="isShort ? '查看' : '阅读全文'"> <time :datetime="post.published_at">{{ relativeDate(post.published_at) }}</time> diff --git a/frontend/src/ui/yohaku/YohakuArticle.vue b/frontend/src/ui/yohaku/YohakuArticle.vue index ffb13f5..6dca2ba 100644 --- a/frontend/src/ui/yohaku/YohakuArticle.vue +++ b/frontend/src/ui/yohaku/YohakuArticle.vue @@ -3,6 +3,7 @@ import { computed, onBeforeUnmount, onMounted, ref } from 'vue' import { sanitizeHtml } from '../../utils' import { openLightbox } from '../../lightbox' import CommentSection from '../../components/comments/CommentSection.vue' +import LinkCard from '../../components/LinkCard.vue' // 余白文章页。对应设计稿 viewPost() 的三栏「纸」: // 左 side-l : 时间线(当前篇前后各两篇,标签来自位置) @@ -186,6 +187,9 @@ function onTocClick(e) { /> </div> + <!-- 外链预览卡片:正文里第一个链接,发布时后端已抓好 --> + <LinkCard v-if="isShort && props.post.link_card" :card="props.post.link_card" /> + <div class="end"> <RouterLink v-for="t in props.post.tags || []" :key="t" class="tg" to="/tags">{{ t }}</RouterLink> </div> diff --git a/frontend/src/ui/yohaku/YohakuFeed.vue b/frontend/src/ui/yohaku/YohakuFeed.vue index 4b07eb0..8ea5aa4 100644 --- a/frontend/src/ui/yohaku/YohakuFeed.vue +++ b/frontend/src/ui/yohaku/YohakuFeed.vue @@ -2,6 +2,7 @@ import { RouterLink } from 'vue-router' import { openLightbox } from '../../lightbox' import { thumbURL, thumbifyHtml } from '../../utils' +import LinkCard from '../../components/LinkCard.vue' // 余白列表。对应设计稿的 <div class="feed">:长文是卡片(封面 + 标题 + 摘要 + 元信息 + 标签), // 短文是纸条(直接铺正文,元信息在正文后)。两种密度混在同一条时间线里,这是设计稿的主张。 @@ -72,6 +73,9 @@ const shortTitle = (p) => plain(p.content_html).slice(0, 40) + '…' @click.prevent.stop="openLightbox(p.images, i)" /> </div> + <!-- 外链预览卡片:正文里第一个链接,发布时后端已抓好 --> + <LinkCard v-if="p.link_card" :card="p.link_card" /> + <div class="meta"> <span class="badge short">SHORT</span> <time :datetime="p.published_at">{{ fmtDateTime(p.published_at) }}</time> diff --git a/frontend/src/views/PostView.vue b/frontend/src/views/PostView.vue index f242ad9..3d47ff0 100644 --- a/frontend/src/views/PostView.vue +++ b/frontend/src/views/PostView.vue @@ -5,6 +5,7 @@ import LeftNav from '../components/LeftNav.vue' import RightRail from '../components/RightRail.vue' import YohakuArticle from '../ui/yohaku/YohakuArticle.vue' import CommentSection from '../components/comments/CommentSection.vue' +import LinkCard from '../components/LinkCard.vue' import { openLightbox } from '../lightbox' import { publicApi } from '../api' import { site, applyDocTitle } from '../site' @@ -124,6 +125,9 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp /> </div> + <!-- 外链预览卡片:正文里第一个链接,发布时后端已抓好 --> + <LinkCard v-if="isShort && post.link_card" :card="post.link_card" /> + <footer class="foot"> <div v-if="post.tags && post.tags.length" class="tags"> <RouterLink