fix utils resource boundaries
This commit is contained in:
@@ -39,6 +39,7 @@ xlgo 框架更新日志。本文档遵循 [Keep a Changelog](https://keepachange
|
||||
- **M10 分布式锁参数与未获锁语义修复**:锁 TTL 统一校验到 Redis 毫秒粒度;`TryLock` 的非正 retry interval 不再 busy-loop;`WithLockAutoExtend` 的非正 extend interval 不再触发 goroutine panic;`UnlockByKey` 在 Redis 未初始化时与 `ForceUnlock` 一样返回 `ErrRedisNotReady`。
|
||||
- **M10 cache 剩余错误语义收口**:新增 `cache.ExistsE` 与可选 `CacheExistChecker`,让调用方能区分 key 不存在和 Redis/backend 故障;保留旧 `Exists` bool-only 兼容方法但记录后端错误;`KeyBuilder` 现在忽略 nil option,`WithPrefix` / `WithSeparator` / `WithCacheType` 直接作用于 nil builder 时 no-op,避免扩展配置路径 panic。
|
||||
- **M11 SSE 换行注入修复**:`WriteEvent` 拒绝带 CR/LF 的 event 名,`WriteMessage` / `WriteEvent` 的 data 按 SSE 多行格式逐行输出,避免用户数据伪造额外 `event:`/`id:` 字段。
|
||||
- **M15 utils/validation 资源与错误边界修复**:`HTTPClient.Upload` 改为流式 multipart 上传,不再把文件请求体完整缓存在内存中;`AppendFile` / `CopyFile` 返回写侧 `Close` 错误;`CheckPasswordAndUpgrade` 归一化非法 `targetCost`,避免异常配置触发超高 bcrypt cost;`ValidateStruct(nil)` 直接返回 nil。
|
||||
- **M12 storage/compress 安全边界修复**:本地上传写侧 `Close` 错误会通过返回值暴露并清理残片;OSS `GetSignedURL` 统一经过 object key 净化;`UnzipWithOptions` 解析目标绝对路径失败时 fail-closed。
|
||||
|
||||
- **M13 cron handler panic 未 recover 崩进程**(cron/cron.go):`RunTask` 与 `checkAndRun` 调度 goroutine 统一经新增 `executeTask(t)` 边界 `recover`,panic 转为 error(含 `debug.Stack` 调用栈)记入 `task.LastError` 并向上返回,不再终止进程。外侧 `defer wg.Done()`/`running` 守卫释放不受影响(recover 在边界内完成)。顺带修复 `RunTask` 手动路径此前只更 `LastRun/RunCount`、不记 `LastError` 的子问题(现与调度路径一致)。
|
||||
|
||||
+10
-3
@@ -1,6 +1,7 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -68,8 +69,10 @@ func AppendFile(path string, data []byte) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = f.Write(data)
|
||||
if cerr := f.Close(); cerr != nil {
|
||||
return errors.Join(err, cerr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -80,7 +83,9 @@ func CopyFile(dst, src string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer srcFile.Close()
|
||||
defer func() {
|
||||
_ = srcFile.Close()
|
||||
}()
|
||||
|
||||
// 确保目标目录存在
|
||||
dir := filepath.Dir(dst)
|
||||
@@ -93,9 +98,11 @@ func CopyFile(dst, src string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dstFile.Close()
|
||||
|
||||
_, err = io.Copy(dstFile, srcFile)
|
||||
if cerr := dstFile.Close(); cerr != nil {
|
||||
return errors.Join(err, cerr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
+122
-128
@@ -19,54 +19,42 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// HTTPClient HTTP 客户端封装
|
||||
// HTTPClient wraps an http.Client with shared runtime configuration.
|
||||
type HTTPClient struct {
|
||||
// mu 保护 client/transport/cfg/timeout/skipTLS 的并发读写。SetSkipTLS/SetTimeout
|
||||
// 在写锁下重建 client/transport;do/DoWithResponse/Close 在读锁下快照后无锁调用
|
||||
// http.Client.Do(Do 自身并发安全),不持有锁发请求以避免序列化。
|
||||
mu sync.RWMutex
|
||||
client *http.Client
|
||||
transport *http.Transport
|
||||
cfg HTTPClientConfig // 保留配置以便 SetSkipTLS 重建 transport
|
||||
cfg HTTPClientConfig
|
||||
timeout time.Duration
|
||||
headers map[string]string
|
||||
cookies map[string]string
|
||||
skipTLS bool
|
||||
// maxRespBodySize 响应体读取上限(字节),0 表示用默认 32MB,-1 表示不限。
|
||||
// 防止恶意/异常服务端返回超大响应打爆内存(C5/N5)。
|
||||
// maxRespBodySize caps response bodies read by do. 0 uses the default, -1 disables the cap.
|
||||
maxRespBodySize int64
|
||||
}
|
||||
|
||||
// UploadFile 上传文件信息
|
||||
// UploadFile describes a file field in a multipart upload.
|
||||
type UploadFile struct {
|
||||
FieldName string // 表单字段名
|
||||
FilePath string // 文件路径
|
||||
FieldName string
|
||||
FilePath string
|
||||
}
|
||||
|
||||
// HTTPClientConfig HTTP 客户端配置
|
||||
// HTTPClientConfig configures HTTPClient.
|
||||
type HTTPClientConfig struct {
|
||||
Timeout time.Duration // 请求超时时间
|
||||
MaxIdleConns int // 最大空闲连接数
|
||||
IdleConnTimeout time.Duration // 空闲连接超时时间
|
||||
MaxConnsPerHost int // 每个主机最大连接数
|
||||
MaxIdleConnsPerHost int // 每个主机最大空闲连接数
|
||||
SkipTLSVerify bool // 是否跳过 TLS 验证(默认 false 校验 TLS;自签证书场景需显式设 true)
|
||||
// MaxResponseBodySize 响应体读取上限(字节)。0 = 默认 32MB,-1 = 不限制。
|
||||
// 防止异常服务端返回超大响应打爆内存(C5/N5)。
|
||||
MaxResponseBodySize int64
|
||||
// BlockPrivateNetworks 启用 SSRF 防护(P0,默认 false 保持兼容)。启用后,连接建立时
|
||||
// 校验解析出的目标 IP,拒绝回环/私有(RFC1918+ULA)/链路本地/元数据(169.254.169.254)/
|
||||
// 未指定/多播等内网地址。校验在 DialContext.Control 中进行,对重定向的每一跳同样生效。
|
||||
// 当 URL 可能来自用户输入(webhook、头像抓取等)时应开启。
|
||||
Timeout time.Duration
|
||||
MaxIdleConns int
|
||||
IdleConnTimeout time.Duration
|
||||
MaxConnsPerHost int
|
||||
MaxIdleConnsPerHost int
|
||||
SkipTLSVerify bool
|
||||
MaxResponseBodySize int64
|
||||
BlockPrivateNetworks bool
|
||||
}
|
||||
|
||||
// ErrSSRFBlocked 目标 IP 落在被拦截网段(SSRF 防护,P0)。
|
||||
var ErrSSRFBlocked = errors.New("ssrf guard: 目标 IP 属被拦截网段(回环/私有/链路本地/元数据)")
|
||||
// ErrSSRFBlocked indicates that SSRF protection blocked the target IP.
|
||||
var ErrSSRFBlocked = errors.New("ssrf guard: target IP is in a blocked network range")
|
||||
|
||||
// isBlockedIP 判断 IP 是否应被 SSRF 防护拦截。
|
||||
// 覆盖:回环、私有(RFC1918 + ULA fc00::/7)、链路本地(含 169.254.169.254 云元数据 / fe80::)、
|
||||
// 未指定(0.0.0.0/::)、多播。
|
||||
// isBlockedIP reports whether ip belongs to a blocked network range.
|
||||
func isBlockedIP(ip net.IP) bool {
|
||||
if ip == nil {
|
||||
return true
|
||||
@@ -76,8 +64,7 @@ func isBlockedIP(ip net.IP) bool {
|
||||
ip.IsUnspecified() || ip.IsMulticast() || ip.IsInterfaceLocalMulticast()
|
||||
}
|
||||
|
||||
// ssrfControl 是 net.Dialer.Control 回调:在 DNS 解析后、真正 dial 前校验目标 IP,
|
||||
// 命中内网段即拒绝。放在 Control 层可覆盖 DNS 重绑定与重定向的每一跳(P0)。
|
||||
// ssrfControl blocks connections to private, loopback, link-local, unspecified, and multicast IPs.
|
||||
func ssrfControl(network, address string, _ syscall.RawConn) error {
|
||||
host, _, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
@@ -85,7 +72,7 @@ func ssrfControl(network, address string, _ syscall.RawConn) error {
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil {
|
||||
return fmt.Errorf("%w: 无法解析地址 %q", ErrSSRFBlocked, address)
|
||||
return fmt.Errorf("%w: cannot parse address %q", ErrSSRFBlocked, address)
|
||||
}
|
||||
if isBlockedIP(ip) {
|
||||
return fmt.Errorf("%w: %s", ErrSSRFBlocked, ip)
|
||||
@@ -93,32 +80,31 @@ func ssrfControl(network, address string, _ syscall.RawConn) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DefaultHTTPClientConfig 默认配置
|
||||
// DefaultHTTPClientConfig is the package default HTTP client configuration.
|
||||
var DefaultHTTPClientConfig = HTTPClientConfig{
|
||||
Timeout: 30 * time.Second,
|
||||
MaxIdleConns: 100,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
MaxConnsPerHost: 10,
|
||||
MaxIdleConnsPerHost: 10,
|
||||
SkipTLSVerify: false, // H2 修复:默认校验 TLS,防 MITM;自签证书需显式 SetSkipTLS(true)
|
||||
SkipTLSVerify: false,
|
||||
MaxResponseBodySize: 32 * 1024 * 1024,
|
||||
}
|
||||
|
||||
// NewHTTPClient 创建 HTTP 客户端
|
||||
// NewHTTPClient creates an HTTP client with the default configuration.
|
||||
func NewHTTPClient() *HTTPClient {
|
||||
cfg := DefaultHTTPClientConfig
|
||||
return NewHTTPClientWithConfig(cfg)
|
||||
}
|
||||
|
||||
// NewSSRFSafeHTTPClient 创建启用 SSRF 防护的 HTTP 客户端(P0):拒绝连接内网目标 IP。
|
||||
// 适用于目标 URL 可能来自用户输入的场景(webhook 回调、远程图片抓取等)。
|
||||
// NewSSRFSafeHTTPClient creates an HTTP client with SSRF protection enabled.
|
||||
func NewSSRFSafeHTTPClient() *HTTPClient {
|
||||
cfg := DefaultHTTPClientConfig
|
||||
cfg.BlockPrivateNetworks = true
|
||||
return NewHTTPClientWithConfig(cfg)
|
||||
}
|
||||
|
||||
// NewHTTPClientWithConfig 使用自定义配置创建 HTTP 客户端
|
||||
// NewHTTPClientWithConfig creates an HTTP client with cfg.
|
||||
func NewHTTPClientWithConfig(cfg HTTPClientConfig) *HTTPClient {
|
||||
transport, client := buildHTTPClientPair(cfg)
|
||||
return &HTTPClient{
|
||||
@@ -133,12 +119,10 @@ func NewHTTPClientWithConfig(cfg HTTPClientConfig) *HTTPClient {
|
||||
}
|
||||
}
|
||||
|
||||
// buildHTTPClientPair 按 cfg 构造 (transport, client) 对。SetSkipTLS 重建时复用。
|
||||
// buildHTTPClientPair builds a transport/client pair from cfg.
|
||||
func buildHTTPClientPair(cfg HTTPClientConfig) (*http.Transport, *http.Client) {
|
||||
// Transport 在初始化时创建,连接池可复用
|
||||
transport := &http.Transport{
|
||||
// #nosec G402 -- InsecureSkipVerify 仅在调用方显式设 cfg.SkipTLSVerify=true 时启用,
|
||||
// 默认 false 校验 TLS(H2 修复)。自签证书场景需 opt-in。
|
||||
// #nosec G402 -- SkipTLSVerify is opt-in; the default verifies TLS.
|
||||
TLSClientConfig: &tls.Config{
|
||||
InsecureSkipVerify: cfg.SkipTLSVerify,
|
||||
},
|
||||
@@ -148,7 +132,6 @@ func buildHTTPClientPair(cfg HTTPClientConfig) (*http.Transport, *http.Client) {
|
||||
MaxIdleConnsPerHost: cfg.MaxIdleConnsPerHost,
|
||||
DisableCompression: false,
|
||||
}
|
||||
// SSRF 防护(P0):启用后为拨号器装 Control 回调,连接建立时拦截内网目标 IP。
|
||||
if cfg.BlockPrivateNetworks {
|
||||
dialer := &net.Dialer{
|
||||
Timeout: 30 * time.Second,
|
||||
@@ -164,36 +147,33 @@ func buildHTTPClientPair(cfg HTTPClientConfig) (*http.Transport, *http.Client) {
|
||||
return transport, client
|
||||
}
|
||||
|
||||
// currentClient 在读锁下快照当前 *http.Client(H-12:与 SetSkipTLS/SetTimeout 重建无竞态)。
|
||||
// currentClient returns the current client snapshot.
|
||||
func (c *HTTPClient) currentClient() *http.Client {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.client
|
||||
}
|
||||
|
||||
// currentTransport 在读锁下快照当前 *http.Transport。
|
||||
// currentTransport returns the current transport snapshot.
|
||||
func (c *HTTPClient) currentTransport() *http.Transport {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.transport
|
||||
}
|
||||
|
||||
// SetTimeout 设置超时时间。
|
||||
// H-12 修复:在写锁下重建 *http.Client(复用 transport 保留连接池),避免与并发 Do 对
|
||||
// client.Timeout 字段的数据竞争。
|
||||
// SetTimeout updates request timeout while preserving the current transport.
|
||||
func (c *HTTPClient) SetTimeout(timeout time.Duration) *HTTPClient {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.timeout = timeout
|
||||
c.cfg.Timeout = timeout
|
||||
_, client := buildHTTPClientPair(c.cfg)
|
||||
// 复用旧 transport 保留连接池:新 client 用旧 transport + 新 timeout。
|
||||
client.Transport = c.transport
|
||||
c.client = client
|
||||
return c
|
||||
}
|
||||
|
||||
// SetHeader 设置请求头(P0:写锁保护,与并发 do/DoWithResponse 读 map 无竞态)。
|
||||
// SetHeader sets a request header for future requests.
|
||||
func (c *HTTPClient) SetHeader(key, value string) *HTTPClient {
|
||||
c.mu.Lock()
|
||||
c.headers[key] = value
|
||||
@@ -201,7 +181,7 @@ func (c *HTTPClient) SetHeader(key, value string) *HTTPClient {
|
||||
return c
|
||||
}
|
||||
|
||||
// SetHeaders 批量设置请求头(P0:写锁保护)。
|
||||
// SetHeaders sets request headers for future requests.
|
||||
func (c *HTTPClient) SetHeaders(headers map[string]string) *HTTPClient {
|
||||
c.mu.Lock()
|
||||
for k, v := range headers {
|
||||
@@ -211,7 +191,7 @@ func (c *HTTPClient) SetHeaders(headers map[string]string) *HTTPClient {
|
||||
return c
|
||||
}
|
||||
|
||||
// SetCookie 设置 Cookie(P0:写锁保护)。
|
||||
// SetCookie sets a cookie for future requests.
|
||||
func (c *HTTPClient) SetCookie(key, value string) *HTTPClient {
|
||||
c.mu.Lock()
|
||||
c.cookies[key] = value
|
||||
@@ -219,8 +199,7 @@ func (c *HTTPClient) SetCookie(key, value string) *HTTPClient {
|
||||
return c
|
||||
}
|
||||
|
||||
// snapshotHeadersCookies 在读锁下拷贝 headers/cookies,供 do/DoWithResponse 在锁外应用到请求,
|
||||
// 消除与 SetHeader/SetHeaders/SetCookie 的 map 读写竞态(P0)。
|
||||
// snapshotHeadersCookies copies mutable header and cookie maps under lock.
|
||||
func (c *HTTPClient) snapshotHeadersCookies() (headers, cookies map[string]string) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
@@ -235,14 +214,7 @@ func (c *HTTPClient) snapshotHeadersCookies() (headers, cookies map[string]strin
|
||||
return headers, cookies
|
||||
}
|
||||
|
||||
// SetSkipTLS 设置是否跳过 TLS 验证。
|
||||
// 跳过 TLS 校验会暴露于 MITM 攻击,仅在受控环境(如自签证书的内网服务)且明确风险时启用,
|
||||
// 生产环境应保持 false。
|
||||
//
|
||||
// H-12 修复:原实现直接覆盖 c.transport.TLSClientConfig 指针,与并发 Do 读取该字段
|
||||
// 存在数据竞争(-race 必采),且注释自承"需重建 Transport"却未重建。改为在写锁下用
|
||||
// 新配置重建 transport+client 并原子替换,旧 transport 释放空闲连接(保留旧 TLS 配置的
|
||||
// idle 连接不再被复用)。支持运行期并发调用与并发请求无竞态。
|
||||
// SetSkipTLS toggles TLS certificate verification for future requests.
|
||||
func (c *HTTPClient) SetSkipTLS(skip bool) *HTTPClient {
|
||||
c.mu.Lock()
|
||||
c.skipTLS = skip
|
||||
@@ -252,16 +224,14 @@ func (c *HTTPClient) SetSkipTLS(skip bool) *HTTPClient {
|
||||
c.transport = transport
|
||||
c.client = client
|
||||
c.mu.Unlock()
|
||||
// 锁外释放旧 transport 的空闲连接(CloseIdleConnections 仅关 idle 连接,不影响在途请求)。
|
||||
// Release idle connections from the old transport outside the lock.
|
||||
if oldTransport != nil {
|
||||
oldTransport.CloseIdleConnections()
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// SetBlockPrivateNetworks 运行期开关 SSRF 防护(P0)。开启后连接内网 IP(回环/私有/链路本地/
|
||||
// 元数据等)会被拒绝并返回 ErrSSRFBlocked。写锁下重建 transport+client 并原子替换,
|
||||
// 与并发请求无竞态;旧 transport 的空闲连接在锁外释放。
|
||||
// SetBlockPrivateNetworks toggles SSRF protection for future requests.
|
||||
func (c *HTTPClient) SetBlockPrivateNetworks(block bool) *HTTPClient {
|
||||
c.mu.Lock()
|
||||
c.cfg.BlockPrivateNetworks = block
|
||||
@@ -276,7 +246,7 @@ func (c *HTTPClient) SetBlockPrivateNetworks(block bool) *HTTPClient {
|
||||
return c
|
||||
}
|
||||
|
||||
// Get 发送 GET 请求
|
||||
// Get sends a GET request.
|
||||
func (c *HTTPClient) Get(urlStr string, params map[string]string) ([]byte, error) {
|
||||
if len(params) > 0 {
|
||||
u, err := url.Parse(urlStr)
|
||||
@@ -298,7 +268,7 @@ func (c *HTTPClient) Get(urlStr string, params map[string]string) ([]byte, error
|
||||
return c.do(req)
|
||||
}
|
||||
|
||||
// Post 发送 POST 请求(form 表单格式)
|
||||
// Post sends a form-encoded POST request.
|
||||
func (c *HTTPClient) Post(urlStr string, params map[string]string) ([]byte, error) {
|
||||
data := url.Values{}
|
||||
for k, v := range params {
|
||||
@@ -313,7 +283,7 @@ func (c *HTTPClient) Post(urlStr string, params map[string]string) ([]byte, erro
|
||||
return c.do(req)
|
||||
}
|
||||
|
||||
// PostJSON 发送 POST 请求(JSON 格式)
|
||||
// PostJSON sends a JSON POST request.
|
||||
func (c *HTTPClient) PostJSON(urlStr string, data any) ([]byte, error) {
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
@@ -328,7 +298,7 @@ func (c *HTTPClient) PostJSON(urlStr string, data any) ([]byte, error) {
|
||||
return c.do(req)
|
||||
}
|
||||
|
||||
// Put 发送 PUT 请求(JSON 格式)
|
||||
// Put sends a JSON PUT request.
|
||||
func (c *HTTPClient) Put(urlStr string, data any) ([]byte, error) {
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
@@ -343,7 +313,7 @@ func (c *HTTPClient) Put(urlStr string, data any) ([]byte, error) {
|
||||
return c.do(req)
|
||||
}
|
||||
|
||||
// Delete 发送 DELETE 请求
|
||||
// Delete sends a DELETE request.
|
||||
func (c *HTTPClient) Delete(urlStr string) ([]byte, error) {
|
||||
req, err := http.NewRequest("DELETE", urlStr, nil)
|
||||
if err != nil {
|
||||
@@ -352,55 +322,85 @@ func (c *HTTPClient) Delete(urlStr string) ([]byte, error) {
|
||||
return c.do(req)
|
||||
}
|
||||
|
||||
// Upload 上传文件
|
||||
// Upload streams multipart files without buffering the full request body in memory.
|
||||
func (c *HTTPClient) Upload(urlStr string, files []UploadFile, params map[string]string) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
writer := multipart.NewWriter(&buf)
|
||||
pr, pw := io.Pipe()
|
||||
writer := multipart.NewWriter(pw)
|
||||
errCh := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
errCh <- writeMultipartUpload(pw, writer, files, params)
|
||||
}()
|
||||
|
||||
req, err := http.NewRequest("POST", urlStr, pr)
|
||||
if err != nil {
|
||||
_ = pr.CloseWithError(err)
|
||||
_ = pw.CloseWithError(err)
|
||||
<-errCh
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
|
||||
resp, err := c.do(req)
|
||||
if err != nil {
|
||||
_ = pr.CloseWithError(err)
|
||||
<-errCh
|
||||
return nil, err
|
||||
}
|
||||
if err := <-errCh; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func writeMultipartUpload(pw *io.PipeWriter, writer *multipart.Writer, files []UploadFile, params map[string]string) (err error) {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
_ = pw.CloseWithError(err)
|
||||
return
|
||||
}
|
||||
if closeErr := writer.Close(); closeErr != nil {
|
||||
_ = pw.CloseWithError(closeErr)
|
||||
err = closeErr
|
||||
return
|
||||
}
|
||||
err = pw.Close()
|
||||
}()
|
||||
|
||||
for _, f := range files {
|
||||
file, err := os.Open(f.FilePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
part, err := writer.CreateFormFile(f.FieldName, filepath.Base(f.FilePath))
|
||||
if err != nil {
|
||||
if cerr := file.Close(); cerr != nil {
|
||||
return nil, errors.Join(err, cerr)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if _, err = io.Copy(part, file); err != nil {
|
||||
if cerr := file.Close(); cerr != nil {
|
||||
return nil, errors.Join(err, cerr)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
// 显式关闭,避免循环内 defer 累积 FD(N5/C5)。
|
||||
if err := file.Close(); err != nil {
|
||||
return nil, err
|
||||
if err = writeMultipartFile(writer, f); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for k, v := range params {
|
||||
if err := writer.WriteField(k, v); err != nil {
|
||||
return nil, err
|
||||
if err = writer.WriteField(k, v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := writer.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", urlStr, &buf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
return c.do(req)
|
||||
return nil
|
||||
}
|
||||
|
||||
// UploadFromBytes 从字节数据上传文件
|
||||
func writeMultipartFile(writer *multipart.Writer, f UploadFile) (err error) {
|
||||
file, err := os.Open(f.FilePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if cerr := file.Close(); cerr != nil {
|
||||
err = errors.Join(err, cerr)
|
||||
}
|
||||
}()
|
||||
|
||||
part, err := writer.CreateFormFile(f.FieldName, filepath.Base(f.FilePath))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(part, file)
|
||||
return err
|
||||
}
|
||||
|
||||
// UploadFromBytes uploads an in-memory byte slice as a multipart file.
|
||||
func (c *HTTPClient) UploadFromBytes(urlStr string, fieldName string, filename string, data []byte, params map[string]string) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
writer := multipart.NewWriter(&buf)
|
||||
@@ -431,7 +431,7 @@ func (c *HTTPClient) UploadFromBytes(urlStr string, fieldName string, filename s
|
||||
return c.do(req)
|
||||
}
|
||||
|
||||
// Request 发送自定义请求
|
||||
// Request sends a request with a caller-provided method and JSON body.
|
||||
func (c *HTTPClient) Request(method, urlStr string, body []byte) ([]byte, error) {
|
||||
req, err := http.NewRequest(method, urlStr, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
@@ -443,9 +443,8 @@ func (c *HTTPClient) Request(method, urlStr string, body []byte) ([]byte, error)
|
||||
return c.do(req)
|
||||
}
|
||||
|
||||
// do 执行请求(使用共享的 client 和 transport)
|
||||
// do executes req and reads a bounded response body.
|
||||
func (c *HTTPClient) do(req *http.Request) ([]byte, error) {
|
||||
// P0:读锁快照 headers/cookies 后在锁外应用,与并发 Set* 无竞态。
|
||||
headers, cookies := c.snapshotHeadersCookies()
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
@@ -455,7 +454,6 @@ func (c *HTTPClient) do(req *http.Request) ([]byte, error) {
|
||||
req.AddCookie(&http.Cookie{Name: k, Value: v})
|
||||
}
|
||||
|
||||
// 发送请求(H-12:快照 client,与 SetSkipTLS/SetTimeout 重建无竞态)
|
||||
// #nosec G704 -- default client intentionally allows caller-supplied URLs for compatibility; use NewSSRFSafeHTTPClient/BlockPrivateNetworks for untrusted URLs.
|
||||
resp, err := c.currentClient().Do(req)
|
||||
if err != nil {
|
||||
@@ -463,20 +461,19 @@ func (c *HTTPClient) do(req *http.Request) ([]byte, error) {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 检查状态码
|
||||
// Treat 4xx/5xx responses as request errors.
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("http error: %d %s", resp.StatusCode, resp.Status)
|
||||
}
|
||||
|
||||
// 响应体读取封顶,防异常服务端返回超大响应打爆内存(C5/N5)。
|
||||
// maxRespBodySize: 0=默认 32MB,-1=不限。
|
||||
// maxRespBodySize: 0 uses the default 32MiB cap; -1 disables the cap.
|
||||
limit := c.maxRespBodySize
|
||||
if limit == 0 {
|
||||
limit = 32 * 1024 * 1024
|
||||
}
|
||||
var reader io.Reader = resp.Body
|
||||
if limit > 0 {
|
||||
// 多读 1 字节用于判断是否超限。
|
||||
// Read one extra byte so over-limit responses can be detected.
|
||||
reader = io.LimitReader(resp.Body, limit+1)
|
||||
}
|
||||
data, err := io.ReadAll(reader)
|
||||
@@ -489,11 +486,8 @@ func (c *HTTPClient) do(req *http.Request) ([]byte, error) {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// DoWithResponse 执行请求并返回完整响应。
|
||||
//
|
||||
// 注意:调用方负责关闭返回的 resp.Body;且此方法不套用 maxRespBodySize 读封顶(由调用方掌控)。
|
||||
// DoWithResponse executes req and returns the raw response; caller must close resp.Body.
|
||||
func (c *HTTPClient) DoWithResponse(req *http.Request) (*http.Response, error) {
|
||||
// P0:读锁快照 headers/cookies 后在锁外应用,与并发 Set* 无竞态。
|
||||
headers, cookies := c.snapshotHeadersCookies()
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
@@ -507,23 +501,23 @@ func (c *HTTPClient) DoWithResponse(req *http.Request) (*http.Response, error) {
|
||||
return c.currentClient().Do(req)
|
||||
}
|
||||
|
||||
// Close 关闭客户端(释放连接池资源)
|
||||
// Close releases idle connections held by the client transport.
|
||||
func (c *HTTPClient) Close() {
|
||||
if t := c.currentTransport(); t != nil {
|
||||
t.CloseIdleConnections()
|
||||
}
|
||||
}
|
||||
|
||||
// JSONMarshal 内部 JSON 序列化函数
|
||||
// JSONMarshal marshals v as JSON.
|
||||
func JSONMarshal(v any) ([]byte, error) {
|
||||
return json.Marshal(v)
|
||||
}
|
||||
|
||||
// 全局默认 HTTP 客户端
|
||||
// defaultClient is the package-level shared HTTP client.
|
||||
var defaultClient *HTTPClient
|
||||
var defaultClientOnce sync.Once
|
||||
|
||||
// DefaultHTTPClient 获取全局默认 HTTP 客户端
|
||||
// DefaultHTTPClient returns the package-level shared HTTP client.
|
||||
func DefaultHTTPClient() *HTTPClient {
|
||||
defaultClientOnce.Do(func() {
|
||||
defaultClient = NewHTTPClient()
|
||||
@@ -531,17 +525,17 @@ func DefaultHTTPClient() *HTTPClient {
|
||||
return defaultClient
|
||||
}
|
||||
|
||||
// HTTPGet 使用默认客户端发送 GET 请求
|
||||
// HTTPGet sends a GET request with the default client.
|
||||
func HTTPGet(url string, params map[string]string) ([]byte, error) {
|
||||
return DefaultHTTPClient().Get(url, params)
|
||||
}
|
||||
|
||||
// HTTPPost 使用默认客户端发送 POST 请求
|
||||
// HTTPPost sends a form POST request with the default client.
|
||||
func HTTPPost(url string, params map[string]string) ([]byte, error) {
|
||||
return DefaultHTTPClient().Post(url, params)
|
||||
}
|
||||
|
||||
// HTTPPostJSON 使用默认客户端发送 JSON POST 请求
|
||||
// HTTPPostJSON sends a JSON POST request with the default client.
|
||||
func HTTPPostJSON(url string, data any) ([]byte, error) {
|
||||
return DefaultHTTPClient().PostJSON(url, data)
|
||||
}
|
||||
|
||||
@@ -3,8 +3,11 @@ package utils_test
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
@@ -246,3 +249,47 @@ func TestHTTPClientSetBlockPrivateNetworks(t *testing.T) {
|
||||
t.Errorf("after enabling guard, err = %v, want ErrSSRFBlocked", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPClientUploadStreamsMultipartBody(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
filePath := filepath.Join(tmp, "payload.txt")
|
||||
if err := os.WriteFile(filePath, []byte("streamed payload"), 0644); err != nil {
|
||||
t.Fatalf("write temp payload: %v", err)
|
||||
}
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.ContentLength != -1 {
|
||||
t.Errorf("ContentLength = %d, want -1 for streaming upload", r.ContentLength)
|
||||
}
|
||||
if err := r.ParseMultipartForm(1024); err != nil {
|
||||
t.Fatalf("ParseMultipartForm: %v", err)
|
||||
}
|
||||
if got := r.FormValue("kind"); got != "test" {
|
||||
t.Errorf("form field kind = %q, want test", got)
|
||||
}
|
||||
file, _, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
t.Fatalf("FormFile: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
data, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
t.Fatalf("read uploaded file: %v", err)
|
||||
}
|
||||
if string(data) != "streamed payload" {
|
||||
t.Errorf("uploaded data = %q", string(data))
|
||||
}
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := utils.NewHTTPClient()
|
||||
defer c.Close()
|
||||
body, err := c.Upload(srv.URL, []utils.UploadFile{{FieldName: "file", FilePath: filePath}}, map[string]string{"kind": "test"})
|
||||
if err != nil {
|
||||
t.Fatalf("Upload: %v", err)
|
||||
}
|
||||
if string(body) != "ok" {
|
||||
t.Errorf("body = %q, want ok", string(body))
|
||||
}
|
||||
}
|
||||
|
||||
+32
-6
@@ -1,6 +1,8 @@
|
||||
package utils_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -92,9 +94,9 @@ func TestDefaultIfBlank(t *testing.T) {
|
||||
|
||||
func TestSubstr(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
start int
|
||||
length int
|
||||
input string
|
||||
start int
|
||||
length int
|
||||
expected string
|
||||
}{
|
||||
{"hello世界", 0, 7, "hello世界"},
|
||||
@@ -334,8 +336,8 @@ func TestCalcOffset(t *testing.T) {
|
||||
{1, 20, 0},
|
||||
{2, 20, 20},
|
||||
{3, 10, 20},
|
||||
{0, 20, 0}, // page <= 0 自动修正为 1
|
||||
{1, 0, 0}, // pageSize <= 0 自动修正为 20
|
||||
{0, 20, 0}, // page <= 0 自动修正为 1
|
||||
{1, 0, 0}, // pageSize <= 0 自动修正为 20
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -346,6 +348,30 @@ func TestCalcOffset(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendFileAndCopyFile(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
src := filepath.Join(tmp, "src.txt")
|
||||
dst := filepath.Join(tmp, "nested", "dst.txt")
|
||||
|
||||
if err := utils.AppendFile(src, []byte("hello")); err != nil {
|
||||
t.Fatalf("AppendFile first write: %v", err)
|
||||
}
|
||||
if err := utils.AppendFile(src, []byte(" world")); err != nil {
|
||||
t.Fatalf("AppendFile second write: %v", err)
|
||||
}
|
||||
if err := utils.CopyFile(dst, src); err != nil {
|
||||
t.Fatalf("CopyFile: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(dst)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile copied file: %v", err)
|
||||
}
|
||||
if string(data) != "hello world" {
|
||||
t.Errorf("copied data = %q, want hello world", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Validator Tests =====
|
||||
|
||||
func TestIsPhone(t *testing.T) {
|
||||
@@ -813,4 +839,4 @@ func BenchmarkCalcPageCount(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
utils.CalcPageCount(10000, 20)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+29
-17
@@ -4,23 +4,36 @@ import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// 默认加密成本(bcrypt 推荐值)
|
||||
// defaultCost is the framework default bcrypt cost.
|
||||
const defaultCost = 12
|
||||
|
||||
// HashPassword 对密码进行加密
|
||||
func normalizeHashCost(cost int) int {
|
||||
if cost < bcrypt.MinCost {
|
||||
return bcrypt.MinCost
|
||||
}
|
||||
if cost > bcrypt.MaxCost {
|
||||
return bcrypt.MaxCost
|
||||
}
|
||||
return cost
|
||||
}
|
||||
|
||||
func normalizeUpgradeTargetCost(cost int) int {
|
||||
if cost <= 0 || cost > bcrypt.MaxCost {
|
||||
return defaultCost
|
||||
}
|
||||
return normalizeHashCost(cost)
|
||||
}
|
||||
|
||||
// HashPassword hashes a plaintext password with the framework default cost.
|
||||
func HashPassword(password string) (string, error) {
|
||||
return HashPasswordWithCost(password, defaultCost)
|
||||
}
|
||||
|
||||
// HashPasswordWithCost 使用指定成本对密码进行加密
|
||||
// cost 范围: 4-31,值越大越安全但越慢
|
||||
// HashPasswordWithCost hashes a plaintext password with a bounded bcrypt cost.
|
||||
// Costs below bcrypt.MinCost are raised to bcrypt.MinCost, and costs above
|
||||
// bcrypt.MaxCost are lowered to bcrypt.MaxCost.
|
||||
func HashPasswordWithCost(password string, cost int) (string, error) {
|
||||
if cost < bcrypt.MinCost {
|
||||
cost = bcrypt.MinCost
|
||||
}
|
||||
if cost > bcrypt.MaxCost {
|
||||
cost = bcrypt.MaxCost
|
||||
}
|
||||
cost = normalizeHashCost(cost)
|
||||
|
||||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), cost)
|
||||
if err != nil {
|
||||
@@ -29,23 +42,22 @@ func HashPasswordWithCost(password string, cost int) (string, error) {
|
||||
return string(bytes), nil
|
||||
}
|
||||
|
||||
// CheckPassword 验证密码是否匹配
|
||||
// hashedPassword 是加密后的密码,password 是明文密码
|
||||
// CheckPassword verifies whether hashedPassword matches the plaintext password.
|
||||
func CheckPassword(hashedPassword, password string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// CheckPasswordAndUpgrade 验证密码并在需要时升级加密成本
|
||||
// 返回:是否匹配、是否需要升级、升级后的密码、错误
|
||||
// CheckPasswordAndUpgrade verifies a password and rehashes it when the stored
|
||||
// cost is lower than the normalized target cost.
|
||||
func CheckPasswordAndUpgrade(hashedPassword, password string, targetCost int) (match bool, needUpgrade bool, newHash string, err error) {
|
||||
// 验证密码
|
||||
targetCost = normalizeUpgradeTargetCost(targetCost)
|
||||
|
||||
err = bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password))
|
||||
if err != nil {
|
||||
return false, false, "", err
|
||||
}
|
||||
|
||||
// 检查是否需要升级
|
||||
cost, err := bcrypt.Cost([]byte(hashedPassword))
|
||||
if err != nil {
|
||||
return true, false, "", nil
|
||||
@@ -62,7 +74,7 @@ func CheckPasswordAndUpgrade(hashedPassword, password string, targetCost int) (m
|
||||
return true, false, "", nil
|
||||
}
|
||||
|
||||
// GetPasswordCost 获取加密密码的成本
|
||||
// GetPasswordCost returns the bcrypt cost encoded in hashedPassword.
|
||||
func GetPasswordCost(hashedPassword string) (int, error) {
|
||||
return bcrypt.Cost([]byte(hashedPassword))
|
||||
}
|
||||
|
||||
@@ -15,10 +15,10 @@ func TestValidatePassword(t *testing.T) {
|
||||
valid bool
|
||||
msg string
|
||||
}{
|
||||
{"Abc12345", true, ""}, // 有效
|
||||
{"Abc12345", true, ""}, // 有效
|
||||
{"abc12345", false, "密码必须包含大写字母"}, // 缺大写
|
||||
{"ABC12345", false, "密码必须包含小写字母"}, // 缺小写
|
||||
{"Abcdefgh", false, "密码必须包含数字"}, // 缺数字
|
||||
{"Abcdefgh", false, "密码必须包含数字"}, // 缺数字
|
||||
{"Abc123", false, "密码长度不能少于8位"}, // 太短
|
||||
{"", false, "密码长度不能少于8位"}, // 空
|
||||
}
|
||||
@@ -193,6 +193,25 @@ func TestCheckPasswordAndUpgrade(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckPasswordAndUpgradeNormalizesInvalidTargetCost(t *testing.T) {
|
||||
password := "testPassword123"
|
||||
hash, err := validation.HashPasswordWithCost(password, 12)
|
||||
if err != nil {
|
||||
t.Fatalf("HashPasswordWithCost: %v", err)
|
||||
}
|
||||
|
||||
match, needUpgrade, newHash, err := validation.CheckPasswordAndUpgrade(hash, password, bcrypt.MaxCost+1)
|
||||
if err != nil {
|
||||
t.Fatalf("CheckPasswordAndUpgrade with high target cost: %v", err)
|
||||
}
|
||||
if !match {
|
||||
t.Fatal("Password should match")
|
||||
}
|
||||
if needUpgrade || newHash != "" {
|
||||
t.Fatalf("invalid high target cost should normalize to default and avoid upgrade, needUpgrade=%v newHash=%q", needUpgrade, newHash)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPasswordCost(t *testing.T) {
|
||||
password := "testPassword123"
|
||||
hash, _ := validation.HashPasswordWithCost(password, 12)
|
||||
@@ -311,6 +330,10 @@ func TestValidateStruct(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestValidateStructNil(t *testing.T) {
|
||||
if errors := validation.ValidateStruct(nil); errors != nil {
|
||||
t.Fatalf("ValidateStruct(nil) = %v, want nil", errors)
|
||||
}
|
||||
|
||||
// 空结构体
|
||||
emptyUser := TestUser{}
|
||||
errors := validation.ValidateStruct(emptyUser)
|
||||
@@ -365,12 +388,12 @@ func TestValidateUsername(t *testing.T) {
|
||||
username string
|
||||
valid bool
|
||||
}{
|
||||
{"abc123", true}, // 有效
|
||||
{"Abc123", true}, // 有效(大写开头)
|
||||
{"user_name", true}, // 有效(包含下划线)
|
||||
{"123abc", false}, // 无效(数字开头)
|
||||
{"ab", false}, // 无效(太短)
|
||||
{"a!bc", false}, // 无效(特殊字符)
|
||||
{"abc123", true}, // 有效
|
||||
{"Abc123", true}, // 有效(大写开头)
|
||||
{"user_name", true}, // 有效(包含下划线)
|
||||
{"123abc", false}, // 无效(数字开头)
|
||||
{"ab", false}, // 无效(太短)
|
||||
{"a!bc", false}, // 无效(特殊字符)
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -435,4 +458,4 @@ func BenchmarkValidatePassword(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
validation.ValidatePassword(password)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,6 +250,10 @@ func validateIDCardChecksum(id string) bool {
|
||||
|
||||
// ValidateStruct 验证结构体
|
||||
func ValidateStruct(s any) ValidationErrors {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
v := Validator.Load()
|
||||
if v == nil {
|
||||
InitValidator()
|
||||
@@ -450,4 +454,4 @@ func BindForm(c *gin.Context, req any) ValidationErrors {
|
||||
return parseValidationErrors(err, req)
|
||||
}
|
||||
return ValidateStruct(req)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user