- 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> 的默认激活行为
108 lines
3.1 KiB
Go
108 lines
3.1 KiB
Go
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
|
||
}
|