编辑器三件套(参考 xLog):CodeMirror 源码模式 + 外链图片一键转存 + 上传进度/前置校验
- 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)
This commit is contained in:
@@ -75,25 +75,7 @@ func (f *Fetcher) Fetch(ctx context.Context, rawURL string) (*Card, error) {
|
||||
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,
|
||||
}
|
||||
|
||||
client := f.client()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -125,6 +107,78 @@ func (f *Fetcher) Fetch(ctx context.Context, rawURL string) (*Card, error) {
|
||||
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")
|
||||
|
||||
Reference in New Issue
Block a user