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:
+98
-49
@@ -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"` // 父级目录 ID,0 表示根目录
|
||||
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"` // 父级目录 ID,0 表示根目录
|
||||
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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user