短文支持链接预览卡片:新增 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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user