Files
ONE/backend/internal/linkmeta/charset.go
T
Sakurasan 661d157b6c 短文支持链接预览卡片:新增 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> 的默认激活行为
2026-09-28 17:13:20 +08:00

108 lines
3.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}