clearlove2.1
1// Package util 通用工具:日志、随机串、bcrypt 密码、HMAC 会话、图形验证码、
2// 图片转 WebP、上传保存、客户端 IP、HTML 清理等。
3package util
4
5import (
6 "bytes"
7 "crypto/hmac"
8 "crypto/rand"
9 "crypto/sha256"
10 "encoding/base64"
11 "encoding/hex"
12 "encoding/json"
13 "fmt"
14 "image"
15 "image/draw"
16 "image/gif"
17 _ "image/jpeg" // 注册 JPEG 解码器
18 _ "image/png" // 注册 PNG 解码器
19 "log"
20 "math"
21 "mime/multipart"
22 "net"
23 "net/http"
24 "os"
25 "path/filepath"
26 "regexp"
27 "strings"
28 "sync"
29 "time"
30
31 webpenc "github.com/HugoSmits86/nativewebp"
32 "golang.org/x/crypto/bcrypt"
33 xdraw "golang.org/x/image/draw"
34
35 "clearlove/internal/config"
36)
37
38// ============ 日志 ============
39
40// Log 统一日志输出(级别 + 时间前缀)
41func Log(level, format string, args ...any) {
42 log.SetFlags(log.Ldate | log.Ltime)
43 log.Printf("[%s] %s", strings.ToUpper(level), fmt.Sprintf(format, args...))
44}
45
46// ============ 随机与密码 ============
47
48// RandomHex 生成 n 字节的十六进制随机串
49func RandomHex(n int) string {
50 b := make([]byte, n)
51 _, _ = rand.Read(b)
52 return hex.EncodeToString(b)
53}
54
55// HashPassword bcrypt 加密密码
56func HashPassword(p string) string {
57 b, _ := bcrypt.GenerateFromPassword([]byte(p), bcrypt.DefaultCost)
58 return string(b)
59}
60
61// CheckPassword 校验密码
62func CheckPassword(hash, p string) bool {
63 return bcrypt.CompareHashAndPassword([]byte(hash), []byte(p)) == nil
64}
65
66// ============ HMAC 签名会话(无状态,不依赖服务端存储) ============
67
68type sessionClaims struct {
69 UID int64 `json:"uid"`
70 Exp int64 `json:"exp"`
71}
72
73// SignSession 生成签名会话串 base64(payload).hex(hmac)
74func SignSession(uid int64, dur time.Duration) string {
75 c := sessionClaims{UID: uid, Exp: time.Now().Add(dur).Unix()}
76 payload, _ := json.Marshal(c)
77 mac := hmac.New(sha256.New, []byte(config.Cfg.Secret))
78 mac.Write(payload)
79 return base64.RawURLEncoding.EncodeToString(payload) + "." + hex.EncodeToString(mac.Sum(nil))
80}
81
82// VerifySession 校验并解析会话,返回用户 ID
83func VerifySession(val string) (int64, bool) {
84 parts := strings.SplitN(val, ".", 2)
85 if len(parts) != 2 {
86 return 0, false
87 }
88 payload, err := base64.RawURLEncoding.DecodeString(parts[0])
89 if err != nil {
90 return 0, false
91 }
92 mac := hmac.New(sha256.New, []byte(config.Cfg.Secret))
93 mac.Write(payload)
94 if !hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(parts[1])) {
95 return 0, false
96 }
97 var c sessionClaims
98 if err := json.Unmarshal(payload, &c); err != nil || c.Exp < time.Now().Unix() {
99 return 0, false
100 }
101 return c.UID, true
102}
103
104// ============ 图形验证码(纯数字4位,SVG 输出,内存过期存储) ============
105
106type captchaEntry struct {
107 Code string
108 Exp int64
109}
110
111var captchas sync.Map
112
113func init() {
114 // 定期回收过期验证码,防止匿名刷 /api/captcha 导致内存无限增长
115 go func() {
116 for {
117 time.Sleep(5 * time.Minute)
118 now := time.Now().Unix()
119 captchas.Range(func(k, v any) bool {
120 if e, ok := v.(captchaEntry); ok && e.Exp <= now {
121 captchas.Delete(k)
122 }
123 return true
124 })
125 }
126 }()
127}
128
129// NewCaptcha 生成验证码,返回 ID 与内联 SVG
130func NewCaptcha() (string, string) {
131 b := make([]byte, 2)
132 _, _ = rand.Read(b)
133 code := fmt.Sprintf("%04d", (int(b[0])<<8|int(b[1]))%10000)
134 id := RandomHex(8)
135 captchas.Store(id, captchaEntry{Code: code, Exp: time.Now().Add(5 * time.Minute).Unix()})
136 return id, captchaSVG(code)
137}
138
139// VerifyCaptcha 校验并消费验证码(一次性;原子取出+删除,防并发重放)
140func VerifyCaptcha(id, code string) bool {
141 v, ok := captchas.LoadAndDelete(id)
142 if !ok {
143 return false
144 }
145 e := v.(captchaEntry)
146 return e.Exp > time.Now().Unix() && e.Code == strings.TrimSpace(code)
147}
148
149// captchaSVG 渲染4位数字验证码为内联 SVG(含干扰线,无外部字体依赖)
150func captchaSVG(code string) string {
151 colors := []string{"#e05575", "#5b8def", "#3aa675", "#c77b3f"}
152 var b strings.Builder
153 b.WriteString(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 44" width="120" height="44">`)
154 b.WriteString(`<rect width="120" height="44" rx="6" fill="#f6f7fb"/>`)
155 seed := time.Now().UnixNano()
156 for i := 0; i < 4; i++ {
157 y := 28 + rand01(seed+int64(i))*6
158 rot := (rand01(seed+int64(i)*7) - 0.5) * 40
159 fmt.Fprintf(&b, `<text x="%d" y="%.0f" font-size="26" font-weight="bold" fill="%s" font-family="monospace" transform="rotate(%.0f %d 28)">%c</text>`,
160 14+i*26, y, colors[i], rot, 14+i*26, code[i])
161 }
162 for i := 0; i < 4; i++ {
163 fmt.Fprintf(&b, `<line x1="%.0f" y1="%.0f" x2="%.0f" y2="%.0f" stroke="%s" stroke-width="1" opacity="0.5"/>`,
164 rand01(seed+int64(i))*110, rand01(seed+int64(i)*3)*40, rand01(seed+int64(i)*11)*110, rand01(seed+int64(i)*13)*40, colors[i%4])
165 }
166 b.WriteString(`</svg>`)
167 return b.String()
168}
169
170// rand01 由种子产生稳定的 [0,1) 伪随机数
171func rand01(seed int64) float64 {
172 h := sha256.Sum256([]byte(fmt.Sprint(seed)))
173 n := int64(h[0])<<24 | int64(h[1])<<16 | int64(h[2])<<8 | int64(h[3])
174 return math.Abs(float64(n)) / float64(1<<32)
175}
176
177// ============ HTML 清理(防 XSS:剥离全部标签与首尾空白) ============
178
179var tagRe = regexp.MustCompile(`<[^>]*>`)
180var ctrlRe = regexp.MustCompile(`[\x00-\x08\x0b\x0c\x0e-\x1f]`)
181
182// StripHTML 移除 HTML 标签与控制字符,保留换行
183func StripHTML(s string) string {
184 s = ctrlRe.ReplaceAllString(s, "")
185 s = strings.ReplaceAll(s, "\r\n", "\n")
186 return strings.TrimSpace(tagRe.ReplaceAllString(s, ""))
187}
188
189// ============ 图片转 WebP 与上传保存 ============
190
191const maxImageDim = 1600 // 图片最长边超过则等比缩小
192
193// ImageToWebP 解码任意已注册格式图片,缩放后编码为 WebP(有损,质量80)
194func ImageToWebP(data []byte) ([]byte, error) {
195 img, _, err := image.Decode(bytes.NewReader(data))
196 if err != nil {
197 // 尝试 GIF 首帧
198 if g, e2 := gif.Decode(bytes.NewReader(data)); e2 == nil {
199 img = g
200 } else {
201 return nil, fmt.Errorf("图片解码失败: %w", err)
202 }
203 }
204 b := img.Bounds()
205 if b.Dx() > maxImageDim || b.Dy() > maxImageDim {
206 scale := math.Min(float64(maxImageDim)/float64(b.Dx()), float64(maxImageDim)/float64(b.Dy()))
207 dst := image.NewNRGBA(image.Rect(0, 0, int(float64(b.Dx())*scale), int(float64(b.Dy())*scale)))
208 xdraw.CatmullRom.Scale(dst, dst.Bounds(), img, b, xdraw.Over, nil)
209 img = dst
210 }
211 // 统一转为 NRGBA 以满足编码器要求
212 if n, ok := img.(*image.NRGBA); ok {
213 // 直接复用
214 var buf bytes.Buffer
215 if err := webpenc.Encode(&buf, n, &webpenc.Options{UseExtendedFormat: false}); err != nil {
216 return nil, err
217 }
218 return buf.Bytes(), nil
219 }
220 n := image.NewNRGBA(image.Rect(0, 0, b.Dx(), b.Dy()))
221 draw.Draw(n, n.Bounds(), img, b.Min, draw.Src)
222 var buf bytes.Buffer
223 if err := webpenc.Encode(&buf, n, &webpenc.Options{UseExtendedFormat: false}); err != nil {
224 return nil, err
225 }
226 return buf.Bytes(), nil
227}
228
229var imageExts = map[string]bool{".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true}
230var videoExts = map[string]bool{".mp4": true, ".webm": true, ".mov": true, ".m4v": true}
231
232// 上传限制常量
233const (
234 MaxImageSize = 10 << 20 // 单张图片 10MB
235 MaxVideoSize = 70 << 20 // 单个视频 70MB
236 MaxImages = 15 // 图片数量上限
237 MaxVideos = 3 // 视频数量上限
238)
239
240// IsImageExt 是否为受支持的图片扩展名
241func IsImageExt(ext string) bool { return imageExts[strings.ToLower(ext)] }
242
243// IsVideoExt 是否为受支持的视频扩展名
244func IsVideoExt(ext string) bool { return videoExts[strings.ToLower(ext)] }
245
246// SaveImage 保存上传图片(自动转 WebP),返回可访问 URL 路径
247func SaveImage(fh *multipart.FileHeader) (string, error) {
248 if fh.Size > MaxImageSize {
249 return "", fmt.Errorf("单张图片不能超过 10MB")
250 }
251 ext := strings.ToLower(filepath.Ext(fh.Filename))
252 if !imageExts[ext] {
253 return "", fmt.Errorf("不支持的图片格式: %s", ext)
254 }
255 f, err := fh.Open()
256 if err != nil {
257 return "", err
258 }
259 defer f.Close()
260 data, err := io_ReadAll(f)
261 if err != nil {
262 return "", err
263 }
264 webp, err := ImageToWebP(data)
265 if err != nil {
266 return "", err
267 }
268 return writeToUpload(webp, ".webp")
269}
270
271// SaveImageBytes 保存内存中的图片数据(自动转 WebP),返回可访问 URL 路径。
272// 供插件运行时(clv.media.saveImage)等无法拿到 multipart.FileHeader 的场景使用。
273func SaveImageBytes(data []byte) (string, error) {
274 if len(data) > MaxImageSize {
275 return "", fmt.Errorf("单张图片不能超过 10MB")
276 }
277 webp, err := ImageToWebP(data)
278 if err != nil {
279 return "", err
280 }
281 return writeToUpload(webp, ".webp")
282}
283
284// SaveVideo 保存上传视频(校验大小与扩展名),返回可访问 URL 路径
285func SaveVideo(fh *multipart.FileHeader) (string, error) {
286 if fh.Size > MaxVideoSize {
287 return "", fmt.Errorf("单个视频不能超过 70MB")
288 }
289 ext := strings.ToLower(filepath.Ext(fh.Filename))
290 if !videoExts[ext] {
291 return "", fmt.Errorf("不支持的视频格式: %s", ext)
292 }
293 f, err := fh.Open()
294 if err != nil {
295 return "", err
296 }
297 defer f.Close()
298 return saveStream(f, ext)
299}
300
301// SaveVideoBytes 保存内存中的视频数据(校验扩展名与大小),返回可访问 URL 路径。
302// 供插件运行时(clv.media.saveVideo)使用。
303func SaveVideoBytes(data []byte, filename string) (string, error) {
304 if len(data) > MaxVideoSize {
305 return "", fmt.Errorf("单个视频不能超过 70MB")
306 }
307 ext := strings.ToLower(filepath.Ext(filename))
308 if !videoExts[ext] {
309 return "", fmt.Errorf("不支持的视频格式: %s", ext)
310 }
311 return writeToUpload(data, ext)
312}
313
314func saveStream(f multipart.File, ext string) (string, error) {
315 sub := time.Now().UTC().Format("20060102")
316 dir := filepath.Join(config.Cfg.UploadDir, sub)
317 if err := os.MkdirAll(dir, 0o755); err != nil {
318 return "", err
319 }
320 name := RandomHex(8) + ext
321 dst, err := os.Create(filepath.Join(dir, name))
322 if err != nil {
323 return "", err
324 }
325 defer dst.Close()
326 if _, err := dst.ReadFrom(f); err != nil {
327 return "", err
328 }
329 return "/uploads/" + sub + "/" + name, nil
330}
331
332func writeToUpload(data []byte, ext string) (string, error) {
333 sub := time.Now().UTC().Format("20060102")
334 dir := filepath.Join(config.Cfg.UploadDir, sub)
335 if err := os.MkdirAll(dir, 0o755); err != nil {
336 return "", err
337 }
338 name := RandomHex(8) + ext
339 if err := os.WriteFile(filepath.Join(dir, name), data, 0o644); err != nil {
340 return "", err
341 }
342 return "/uploads/" + sub + "/" + name, nil
343}
344
345// RemoveUpload 删除一个已上传的媒体文件(medias.path 形如 /uploads/<日期>/<名>)。
346// 路径合法性校验:必须以 /uploads/ 开头且不含 ..,防目录穿越。文件不存在时静默成功。
347func RemoveUpload(urlPath string) {
348 p := strings.TrimPrefix(urlPath, "/uploads/")
349 if p == urlPath || strings.Contains(p, "..") || strings.Contains(p, "/.") {
350 return
351 }
352 _ = os.Remove(filepath.Join(config.Cfg.UploadDir, filepath.FromSlash(p)))
353}
354
355// io_ReadAll 局部封装避免额外命名冲突
356func io_ReadAll(r interface{ Read([]byte) (int, error) }) ([]byte, error) {
357 var buf bytes.Buffer
358 _, err := buf.ReadFrom(r)
359 return buf.Bytes(), err
360}
361
362// ============ 请求辅助 ============
363
364// ClientIP 提取客户端真实 IP(支持反代 X-Forwarded-For / X-Real-IP)。
365// 仅当直连来源为回环/内网地址(即确实经过本机反向代理转发)时才采信代理头,
366// 防止服务直接暴露时攻击者伪造 X-Forwarded-For 绕过 IP 封禁与失败锁定。
367func ClientIP(r *http.Request) string {
368 remote := remoteIP(r)
369 if isTrustedProxy(remote) {
370 if v := r.Header.Get("X-Forwarded-For"); v != "" {
371 return strings.TrimSpace(strings.Split(v, ",")[0])
372 }
373 if v := r.Header.Get("X-Real-IP"); v != "" {
374 return strings.TrimSpace(v)
375 }
376 }
377 return remote
378}
379
380// remoteIP 取 TCP 连接的直连对端地址(去掉端口)
381func remoteIP(r *http.Request) string {
382 host := r.RemoteAddr
383 if i := strings.LastIndex(host, ":"); i > 0 {
384 host = host[:i]
385 }
386 // 去掉 IPv6 zone/方括号
387 host = strings.Trim(host, "[]")
388 return host
389}
390
391// isTrustedProxy 判断直连对端是否为可信代理(回环或 RFC 私有/链路本地地址)
392func isTrustedProxy(ip string) bool {
393 parsed := net.ParseIP(ip)
394 if parsed == nil {
395 return false
396 }
397 return parsed.IsLoopback() || parsed.IsPrivate() || parsed.IsLinkLocalUnicast()
398}
399
400// FingerprintOf 获取请求携带的浏览器指纹(前端 JS 计算后随请求提交)
401func FingerprintOf(r *http.Request) string {
402 fp := r.Header.Get("X-Fingerprint")
403 if fp == "" {
404 fp = r.FormValue("fingerprint")
405 }
406 if len(fp) > 128 {
407 fp = fp[:128]
408 }
409 return fp
410}