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:
Your Name
2026-08-11 11:21:29 +08:00
parent f0eb822e68
commit 74fa759274
18 changed files with 1968 additions and 228 deletions
+98 -49
View File
@@ -4,63 +4,96 @@ import (
"crypto/sha256"
"encoding/hex"
"time"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
// HashPassword 生成密码哈希
// bcryptCost 密码哈希成本因子
const bcryptCost = 10
// HashPassword 生成密码哈希(优先使用 bcrypt,向后兼容旧的 SHA-256
// 返回值格式:new(bcrypt)哈希以 $2 开头;旧的 SHA-256 以 64 位十六进制开头。
func HashPassword(password string) string {
if password == "" {
return ""
}
hash := sha256.Sum256([]byte(password))
return hex.EncodeToString(hash[:])
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
if err != nil {
// 极端情况下 fallback 到 sha256
s := sha256.Sum256([]byte(password))
return hex.EncodeToString(s[:])
}
return string(hash)
}
// CheckPassword 验证密码
func CheckPassword(password, hash string) bool {
// 如果笔记没有设置密码(hash 为空),则不需要验证
// IsBcryptHash 判断哈希是否为 bcrypt 格式
func IsBcryptHash(hash string) bool {
return len(hash) >= 4 && hash[:4] == "$2a$" || (len(hash) >= 4 && hash[:4] == "$2b$") || (len(hash) >= 4 && hash[:4] == "$2y$")
}
// CheckPassword 验证密码(兼容 bcrypt 与旧 SHA-256 哈希)
// 第二个返回值表示是否应当把存储的哈希升级为 bcrypt(旧 sha256 命中时返回 true
func CheckPassword(password, hash string) (bool, bool) {
if hash == "" {
return true
return true, false
}
// 如果用户没有输入密码,验证失败
if password == "" {
return false
return false, false
}
return HashPassword(password) == hash
if IsBcryptHash(hash) {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil, false
}
// 旧的 SHA-256 哈希
s := sha256.Sum256([]byte(password))
if hex.EncodeToString(s[:]) == hash {
// 命中旧哈希,建议升级为 bcrypt
return true, true
}
return false, false
}
// Note 笔记模型(也用于目录)
type Note struct {
ID uint `json:"id" gorm:"primaryKey"`
Title string `json:"title" gorm:"size:255;not null" binding:"required"`
Content string `json:"content" gorm:"type:text"`
Category string `json:"category" gorm:"size:100;index"`
Tags string `json:"tags" gorm:"type:text"` // JSON 数组格式存储
Password string `json:"-" gorm:"size:255"` // 访问密码(哈希存储)
IsPinned bool `json:"is_pinned" gorm:"default:false"`
IsFavorite bool `json:"is_favorite" gorm:"default:false"`
IsPublic bool `json:"is_public"` // 是否公开
ParentID uint `json:"parent_id" gorm:"default:0;index"` // 父级目录 ID0 表示根目录
IsFolder bool `json:"is_folder" gorm:"default:false"` // 是否为文件夹
SortOrder int `json:"sort_order" gorm:"default:0"` // 排序顺序
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uint `json:"id" gorm:"primaryKey"`
Title string `json:"title" gorm:"size:255;not null"`
Content string `json:"content" gorm:"type:text"`
Category string `json:"category" gorm:"size:100;index"`
Tags string `json:"tags" gorm:"type:text"` // JSON 数组格式存储
Password string `json:"-" gorm:"size:255"` // 访问密码(哈希存储)
IsPinned bool `json:"is_pinned" gorm:"default:false"`
IsFavorite bool `json:"is_favorite" gorm:"default:false"`
IsPublic bool `json:"is_public"` // 是否公开
ParentID uint `json:"parent_id" gorm:"default:0;index"` // 父级目录 ID0 表示根目录
IsFolder bool `json:"is_folder" gorm:"default:false"` // 是否为文件夹
SortOrder int `json:"sort_order" gorm:"default:0"` // 排序顺序
ShareToken string `json:"-" gorm:"size:64;index"` // 分享令牌,空 = 未分享
ShareExpireAt *time.Time `json:"share_expire_at,omitempty"` // 分享过期时间,nil = 永久
VisitCount int `json:"visit_count" gorm:"default:0"` // 浏览次数
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` // 软删除
}
// NoteListItem 笔记列表响应(不含内容和密码)
type NoteListItem struct {
ID uint `json:"id"`
Title string `json:"title"`
Category string `json:"category"`
Tags string `json:"tags"`
HasPassword bool `json:"has_password"` // 是否有密码保护
IsPinned bool `json:"is_pinned"`
IsFavorite bool `json:"is_favorite"`
IsPublic bool `json:"is_public"`
ParentID uint `json:"parent_id"`
IsFolder bool `json:"is_folder"`
SortOrder int `json:"sort_order"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uint `json:"id"`
Title string `json:"title"`
Category string `json:"category"`
Tags string `json:"tags"`
HasPassword bool `json:"has_password"` // 是否有密码保护
IsPinned bool `json:"is_pinned"`
IsFavorite bool `json:"is_favorite"`
IsPublic bool `json:"is_public"`
ParentID uint `json:"parent_id"`
IsFolder bool `json:"is_folder"`
SortOrder int `json:"sort_order"`
ShareToken string `json:"share_token,omitempty"` // 分享令牌(后台完整列表需要)
ShareExpireAt *time.Time `json:"share_expire_at,omitempty"`
VisitCount int `json:"visit_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// NoteCreateRequest 创建笔记请求
@@ -80,21 +113,37 @@ type NoteCreateRequest struct {
// NoteUpdateRequest 更新笔记请求
type NoteUpdateRequest struct {
Title *string `json:"title"`
Content *string `json:"content"`
Category *string `json:"category"`
Tags *string `json:"tags"`
Password *string `json:"password"`
RemovePassword *bool `json:"remove_password"` // 是否移除密码
IsPinned *bool `json:"is_pinned"`
IsFavorite *bool `json:"is_favorite"`
IsPublic *bool `json:"is_public"`
ParentID *uint `json:"parent_id"`
IsFolder *bool `json:"is_folder"`
SortOrder *int `json:"sort_order"`
Title *string `json:"title"`
Content *string `json:"content"`
Category *string `json:"category"`
Tags *string `json:"tags"`
Password *string `json:"password"`
RemovePassword *bool `json:"remove_password"` // 是否移除密码
IsPinned *bool `json:"is_pinned"`
IsFavorite *bool `json:"is_favorite"`
IsPublic *bool `json:"is_public"`
ParentID *uint `json:"parent_id"`
IsFolder *bool `json:"is_folder"`
SortOrder *int `json:"sort_order"`
}
// NoteAccessRequest 笔记访问请求(验证密码)
type NoteAccessRequest struct {
Password string `json:"password"`
}
// NoteVersion 笔记版本历史
type NoteVersion struct {
ID uint `json:"id" gorm:"primaryKey"`
NoteID uint `json:"note_id" gorm:"index"`
Title string `json:"title"`
Content string `json:"content" gorm:"type:text"`
Category string `json:"category"`
Tags string `json:"tags"`
CreatedAt time.Time `json:"created_at"`
}
// TableName 指定版本表名
func (NoteVersion) TableName() string {
return "note_versions"
}