- md 页签从裸 textarea 升级 CodeMirror 6:markdown 语法高亮、行内历史,粘贴/拖图直传; markdown 仍是唯一真相源,转存/切换用 syncCM 全量灌回,工具栏选区辅助改为面向 CM - POST /api/admin/files/import:外链抓取复用 linkmeta SSRF 防护拨号与跳转限制, 二进制传输放宽 30s;类型按内容嗅探反查白名单,与手动上传共用去重/落盘(persistFile 抽取共享) - 上传走 XHR 进度回调 + 编辑器 toast 栈:类型/大小前置校验(与后端白名单一致)、逐文件进度 - persistFile 去重路径补 URL 解析——此前命中去重返回的行没有 URL,转存替换会把正文图链清空(已修复受损数据) - hub nil 安全(测试环境未装配时不 panic)
243 lines
7.7 KiB
Go
243 lines
7.7 KiB
Go
// 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 只读文档开头就够拿到 <head>,多一个字节都不多读。
|
|
maxBody = 512 << 10
|
|
// maxRedirects 跳转链上限。
|
|
maxRedirects = 3
|
|
// 浏览器形态的 UA:不少站点(X、微博、部分门户)对未知爬虫 UA 给的
|
|
// og 元数据不全或直接拒绝,对浏览器 UA 则照常输出 <head> 里的标签。
|
|
userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
|
|
)
|
|
|
|
// 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")
|
|
}
|
|
|
|
client := f.client()
|
|
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
|
|
}
|
|
|
|
// client 组装带 SSRF 防护的 HTTP 客户端(Fetch 与 FetchBytes 共用)
|
|
func (f *Fetcher) client() *http.Client {
|
|
dial := f.Dial
|
|
if dial == nil {
|
|
dial = safeDial
|
|
}
|
|
timeout := f.Timeout
|
|
if timeout <= 0 {
|
|
timeout = 5 * time.Second
|
|
}
|
|
return &http.Client{
|
|
Transport: &http.Transport{
|
|
DialContext: dial,
|
|
TLSHandshakeTimeout: 3 * time.Second,
|
|
// 每个跳转目标都过一遍 dial(transport 会复用),无需额外校验
|
|
ForceAttemptHTTP2: false,
|
|
},
|
|
Timeout: timeout,
|
|
CheckRedirect: limitRedirects,
|
|
}
|
|
}
|
|
|
|
// FetchBytes 抓二进制内容(外链图片转存用):与 Fetch 共用同一套
|
|
// SSRF 防护与跳转限制,字节数有 maxBytes 硬上限。
|
|
// 返回内容与 Content-Type(响应头缺失时用内容嗅探兜底)。
|
|
func FetchBytes(ctx context.Context, rawURL string, maxBytes int64) ([]byte, string, error) {
|
|
return (&Fetcher{}).fetchBytes(ctx, rawURL, maxBytes)
|
|
}
|
|
|
|
func (f *Fetcher) fetchBytes(ctx context.Context, rawURL string, maxBytes int64) ([]byte, string, 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")
|
|
}
|
|
client := f.client()
|
|
// 二进制传输放宽时限:大图慢链路 5 秒的元信息默认值不够用
|
|
if f.Timeout <= 0 {
|
|
client.Timeout = 30 * time.Second
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
req.Header.Set("User-Agent", userAgent)
|
|
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)
|
|
}
|
|
data, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes+1))
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("linkmeta: read: %w", err)
|
|
}
|
|
if int64(len(data)) > maxBytes {
|
|
return nil, "", fmt.Errorf("linkmeta: exceeds %d bytes", maxBytes)
|
|
}
|
|
ct := resp.Header.Get("Content-Type")
|
|
if ct == "" {
|
|
ct = http.DetectContentType(data)
|
|
}
|
|
return data, ct, 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
|
|
}
|