feat: 云笔记增强 - 安全加固 + 回收站/版本历史/分享/批量导出 + 前端优化
- 安全: 认证改随机token会话(弃固定cookie), 笔记密码SHA256升级为bcrypt(自动迁移), 堵住GET /api/notes/:id泄露带密码笔记, CORS收紧+SameSite防CSRF, 上传图片内容嗅探 - 回收站: 软删除(deleted_at), 列表/恢复/彻底删除/清空, 目录子树连删连恢复 - 版本历史: note_versions表存快照, 每次保存自动留档, 支持查看/回滚 - 分享: 生成随机token分享链接, 支持过期时间, 公开阅读页share.html - 批量导出: 全部笔记打包zip(按目录结构+front matter) - 前端: 深色模式, Mermaid图表, 待办清单checkbox, 字数统计; 后台新增回收站/历史/分享面板和批量导出按钮 - 新增deploy/note-manager.service systemd单元与smoke_test.py
This commit is contained in:
+67
-26
@@ -1,6 +1,8 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -8,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gabriel-vasile/mimetype"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -26,6 +29,27 @@ func (h *ImageHandler) Init() error {
|
||||
return os.MkdirAll(h.uploadDir, 0755)
|
||||
}
|
||||
|
||||
// 允许的图片 MIME 类型(基于内容嗅探,而非仅扩展名)
|
||||
var allowedImageTypes = map[string]bool{
|
||||
"image/jpeg": true,
|
||||
"image/png": true,
|
||||
"image/gif": true,
|
||||
"image/webp": true,
|
||||
"image/bmp": true,
|
||||
"image/svg+xml": false, // SVG 可含脚本,默认禁止,避免 XSS
|
||||
"image/x-icon": false,
|
||||
}
|
||||
|
||||
// allowedExts 扩展名白名单(与内容嗅探双重校验)
|
||||
var allowedExts = map[string]bool{
|
||||
".jpg": true,
|
||||
".jpeg": true,
|
||||
".png": true,
|
||||
".gif": true,
|
||||
".webp": true,
|
||||
".bmp": true,
|
||||
}
|
||||
|
||||
// Upload 上传图片
|
||||
func (h *ImageHandler) Upload(c *gin.Context) {
|
||||
file, err := c.FormFile("image")
|
||||
@@ -34,19 +58,10 @@ func (h *ImageHandler) Upload(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 验证文件类型
|
||||
// 验证扩展名
|
||||
ext := strings.ToLower(filepath.Ext(file.Filename))
|
||||
allowedExts := map[string]bool{
|
||||
".jpg": true,
|
||||
".jpeg": true,
|
||||
".png": true,
|
||||
".gif": true,
|
||||
".webp": true,
|
||||
".bmp": true,
|
||||
}
|
||||
|
||||
if !allowedExts[ext] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "不支持的图片格式,仅支持 jpg、png、gif、webp"})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "不支持的图片格式,仅支持 jpg、png、gif、webp、bmp"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -56,34 +71,60 @@ func (h *ImageHandler) Upload(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 生成唯一文件名
|
||||
filename := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), randomString(8), ext)
|
||||
filepath := filepath.Join(h.uploadDir, filename)
|
||||
// 打开文件做内容嗅探,防止伪造扩展名上传恶意内容
|
||||
src, err := file.Open()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "读取文件失败"})
|
||||
return
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
head := make([]byte, 512)
|
||||
if _, err := src.Read(head); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "读取文件失败"})
|
||||
return
|
||||
}
|
||||
|
||||
mime := mimetype.Detect(head)
|
||||
if !allowedImageTypes[mime.String()] && mime.String() != "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "文件内容不是合法图片"})
|
||||
return
|
||||
}
|
||||
|
||||
// 生成唯一文件名(使用安全随机源)
|
||||
filename := fmt.Sprintf("%d_%s%s", osTimeNano(), secureRandomString(8), ext)
|
||||
targetPath := filepath.Join(h.uploadDir, filename)
|
||||
|
||||
// 保存文件
|
||||
if err := c.SaveUploadedFile(file, filepath); err != nil {
|
||||
if err := c.SaveUploadedFile(file, targetPath); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "保存图片失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 返回访问 URL
|
||||
url := "/uploads/" + filename
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
"message": "上传成功",
|
||||
"data": gin.H{
|
||||
"url": url,
|
||||
"url": "/uploads/" + filename,
|
||||
"mime": mime.String(),
|
||||
"width": 0,
|
||||
"height": 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// randomString 生成随机字符串
|
||||
func randomString(length int) string {
|
||||
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
result := make([]byte, length)
|
||||
for i := range result {
|
||||
result[i] = chars[time.Now().UnixNano()%int64(len(chars))]
|
||||
time.Sleep(time.Nanosecond)
|
||||
}
|
||||
return string(result)
|
||||
// osTimeNano 返回当前纳秒时间戳
|
||||
func osTimeNano() int64 {
|
||||
return time.Now().UnixNano()
|
||||
}
|
||||
|
||||
// secureRandomString 使用 crypto/rand 生成安全的随机十六进制字符串
|
||||
func secureRandomString(bytesLen int) string {
|
||||
b := make([]byte, bytesLen)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
// 兜底(几乎不会发生)
|
||||
return fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user