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:
+204
-18
@@ -1,6 +1,7 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -28,8 +29,11 @@ func NewNoteRepository(dbPath string) (*NoteRepository, error) {
|
||||
return nil, fmt.Errorf("连接数据库失败: %w", err)
|
||||
}
|
||||
|
||||
// 开启外键约束(SQLite 默认关闭)
|
||||
db.Exec("PRAGMA foreign_keys = ON")
|
||||
|
||||
// 自动迁移表结构
|
||||
if err := db.AutoMigrate(&model.Note{}); err != nil {
|
||||
if err := db.AutoMigrate(&model.Note{}, &model.NoteVersion{}); err != nil {
|
||||
return nil, fmt.Errorf("数据库迁移失败: %w", err)
|
||||
}
|
||||
|
||||
@@ -38,10 +42,11 @@ func NewNoteRepository(dbPath string) (*NoteRepository, error) {
|
||||
|
||||
// Create 创建笔记
|
||||
func (r *NoteRepository) Create(note *model.Note) error {
|
||||
return r.db.Select("Title", "Content", "Category", "Tags", "Password", "IsPinned", "IsFavorite", "IsPublic", "ParentID", "IsFolder", "SortOrder").Create(note).Error
|
||||
res := r.db.Select("Title", "Content", "Category", "Tags", "Password", "IsPinned", "IsFavorite", "IsPublic", "ParentID", "IsFolder", "SortOrder", "ShareToken", "ShareExpireAt", "VisitCount").Create(note)
|
||||
return res.Error
|
||||
}
|
||||
|
||||
// GetByID 根据 ID 获取笔记
|
||||
// GetByID 根据 ID 获取笔记(排除已删除)
|
||||
func (r *NoteRepository) GetByID(id uint) (*model.Note, error) {
|
||||
var note model.Note
|
||||
err := r.db.First(¬e, id).Error
|
||||
@@ -51,12 +56,27 @@ func (r *NoteRepository) GetByID(id uint) (*model.Note, error) {
|
||||
return ¬e, nil
|
||||
}
|
||||
|
||||
// GetByIDIncludingDeleted 获取笔记(包含已软删除的,用于回收站恢复)
|
||||
func (r *NoteRepository) GetByIDIncludingDeleted(id uint) (*model.Note, error) {
|
||||
var note model.Note
|
||||
err := r.db.Unscoped().First(¬e, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ¬e, nil
|
||||
}
|
||||
|
||||
// Update 更新笔记
|
||||
func (r *NoteRepository) Update(note *model.Note) error {
|
||||
return r.db.Save(note).Error
|
||||
}
|
||||
|
||||
// Delete 删除笔记
|
||||
// UpdateFields 按字段更新(避免 Save 覆盖所有字段)
|
||||
func (r *NoteRepository) UpdateFields(id uint, fields map[string]interface{}) error {
|
||||
return r.db.Model(&model.Note{}).Where("id = ?", id).Updates(fields).Error
|
||||
}
|
||||
|
||||
// Delete 软删除笔记
|
||||
func (r *NoteRepository) Delete(id uint) error {
|
||||
return r.db.Delete(&model.Note{}, id).Error
|
||||
}
|
||||
@@ -100,7 +120,7 @@ func (r *NoteRepository) List(q ListQuery) ([]model.NoteListItem, int64, error)
|
||||
}
|
||||
|
||||
offset := (q.Page - 1) * q.PageSize
|
||||
err := query.Select("id, title, category, tags, is_pinned, is_favorite, parent_id, is_folder, sort_order, created_at, updated_at").
|
||||
err := query.Select("id, title, category, tags, is_pinned, is_favorite, is_public, parent_id, is_folder, sort_order, visit_count, created_at, updated_at").
|
||||
Order("is_folder DESC, sort_order ASC, updated_at DESC").
|
||||
Offset(offset).
|
||||
Limit(q.PageSize).
|
||||
@@ -109,17 +129,17 @@ func (r *NoteRepository) List(q ListQuery) ([]model.NoteListItem, int64, error)
|
||||
return items, total, err
|
||||
}
|
||||
|
||||
// GetAllTree 获取所有笔记的树形结构
|
||||
// GetAllTree 获取所有笔记的树形结构(含分享信息)
|
||||
func (r *NoteRepository) GetAllTree() ([]model.NoteListItem, error) {
|
||||
var items []model.NoteListItem
|
||||
err := r.db.Model(&model.Note{}).
|
||||
Select("id, title, category, tags, CASE WHEN password != '' THEN 1 ELSE 0 END as has_password, is_pinned, is_favorite, is_public, parent_id, is_folder, sort_order, created_at, updated_at").
|
||||
Select("id, title, category, tags, CASE WHEN password != '' THEN 1 ELSE 0 END as has_password, is_pinned, is_favorite, is_public, parent_id, is_folder, sort_order, share_token, share_expire_at, visit_count, created_at, updated_at").
|
||||
Order("is_folder DESC, sort_order ASC, title ASC").
|
||||
Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
// GetPublicTree 获取公开可见的树形结构(显示所有目录和笔记,访问时再验证密码)
|
||||
// GetPublicTree 获取公开可见的树形结构(前台用,不含分享令牌)
|
||||
func (r *NoteRepository) GetPublicTree() ([]model.NoteListItem, error) {
|
||||
var items []model.NoteListItem
|
||||
err := r.db.Model(&model.Note{}).
|
||||
@@ -129,7 +149,7 @@ func (r *NoteRepository) GetPublicTree() ([]model.NoteListItem, error) {
|
||||
return items, err
|
||||
}
|
||||
|
||||
// GetByParentID 获取指定父目录下的所有项目
|
||||
// GetByParentID 获取指定父目录下的所有项目(排除已删除)
|
||||
func (r *NoteRepository) GetByParentID(parentID uint) ([]model.NoteListItem, error) {
|
||||
var items []model.NoteListItem
|
||||
err := r.db.Model(&model.Note{}).
|
||||
@@ -140,21 +160,121 @@ func (r *NoteRepository) GetByParentID(parentID uint) ([]model.NoteListItem, err
|
||||
return items, err
|
||||
}
|
||||
|
||||
// GetChildrenCount 获取子项数量
|
||||
// GetChildrenCount 获取子项数量(排除已删除)
|
||||
func (r *NoteRepository) GetChildrenCount(parentID uint) (int64, error) {
|
||||
var count int64
|
||||
err := r.db.Model(&model.Note{}).Where("parent_id = ?", parentID).Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
// DeleteWithChildren 删除目录及其下所有内容
|
||||
// DeleteWithChildren 软删除目录及其下所有内容
|
||||
func (r *NoteRepository) DeleteWithChildren(id uint) error {
|
||||
// 先删除所有子项
|
||||
if err := r.db.Where("parent_id = ?", id).Delete(&model.Note{}).Error; err != nil {
|
||||
// 递归收集所有后代 id
|
||||
ids := r.collectDescendants(id)
|
||||
var err error
|
||||
if len(ids) > 0 {
|
||||
err = r.db.Delete(&model.Note{}, ids).Error
|
||||
} else {
|
||||
err = r.db.Delete(&model.Note{}, id).Error
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// collectDescendants 收集目录的所有后代 ID(含自身)
|
||||
func (r *NoteRepository) collectDescendants(id uint) []uint {
|
||||
ids := []uint{id}
|
||||
queue := []uint{id}
|
||||
for len(queue) > 0 {
|
||||
parent := queue[0]
|
||||
queue = queue[1:]
|
||||
var children []uint
|
||||
if err := r.db.Model(&model.Note{}).Where("parent_id = ? AND is_folder = ?", parent, true).Pluck("id", &children).Error; err != nil {
|
||||
continue
|
||||
}
|
||||
for _, c := range children {
|
||||
ids = append(ids, c)
|
||||
queue = append(queue, c)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// ListTrash 回收站列表(只含已软删除项)
|
||||
func (r *NoteRepository) ListTrash() ([]model.NoteListItem, error) {
|
||||
var items []model.NoteListItem
|
||||
err := r.db.Unscoped().Model(&model.Note{}).
|
||||
Where("deleted_at IS NOT NULL").
|
||||
Select("id, title, category, tags, is_pinned, is_favorite, is_public, parent_id, is_folder, sort_order, visit_count, created_at, updated_at").
|
||||
Order("deleted_at DESC").
|
||||
Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
// Restore 从回收站恢复(连带恢复已被软删除的父目录路径不需要特殊处理)
|
||||
func (r *NoteRepository) Restore(id uint) error {
|
||||
// 恢复自身
|
||||
if err := r.db.Unscoped().Model(&model.Note{}).Where("id = ?", id).Update("deleted_at", nil).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 再删除自己
|
||||
return r.db.Delete(&model.Note{}, id).Error
|
||||
// 若其祖先目录也被软删除,一并恢复
|
||||
var note model.Note
|
||||
if err := r.db.Unscoped().First(¬e, id).Error; err == nil && note.ParentID != 0 {
|
||||
var parent model.Note
|
||||
if err := r.db.Unscoped().First(&parent, note.ParentID).Error; err == nil && !parent.DeletedAt.Time.IsZero() {
|
||||
_ = r.db.Unscoped().Model(&model.Note{}).Where("id = ?", parent.ID).Update("deleted_at", nil).Error
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RestoreSubtree 恢复整个子树(含所有已软删除后代),返回恢复的节点数
|
||||
func (r *NoteRepository) RestoreSubtree(rootID uint) error {
|
||||
// 收集整棵子树所有节点 id(Unscoped,含已删除)
|
||||
var ids []uint
|
||||
queue := []uint{rootID}
|
||||
for len(queue) > 0 {
|
||||
parent := queue[0]
|
||||
queue = queue[1:]
|
||||
var children []uint
|
||||
if err := r.db.Unscoped().Model(&model.Note{}).Where("parent_id = ?", parent).Pluck("id", &children).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
ids = append(ids, children...)
|
||||
queue = append(queue, children...)
|
||||
}
|
||||
// 统一恢复所有节点(含自身)
|
||||
ids = append(ids, rootID)
|
||||
return r.db.Unscoped().Model(&model.Note{}).Where("id IN ?", ids).Update("deleted_at", nil).Error
|
||||
}
|
||||
|
||||
// HardDelete 彻底删除(不可恢复)
|
||||
func (r *NoteRepository) HardDelete(id uint) error {
|
||||
return r.db.Unscoped().Delete(&model.Note{}, id).Error
|
||||
}
|
||||
|
||||
// HardDeleteWithChildren 彻底删除目录及所有后代
|
||||
func (r *NoteRepository) HardDeleteWithChildren(id uint) error {
|
||||
ids := r.collectDescendantsIncludingDeleted(id)
|
||||
ids = append(ids, id)
|
||||
return r.db.Unscoped().Delete(&model.Note{}, ids).Error
|
||||
}
|
||||
|
||||
func (r *NoteRepository) collectDescendantsIncludingDeleted(id uint) []uint {
|
||||
var ids []uint
|
||||
queue := []uint{id}
|
||||
for len(queue) > 0 {
|
||||
parent := queue[0]
|
||||
queue = queue[1:]
|
||||
var children []uint
|
||||
if err := r.db.Unscoped().Model(&model.Note{}).Where("parent_id = ? AND is_folder = ?", parent, true).Pluck("id", &children).Error; err != nil {
|
||||
continue
|
||||
}
|
||||
for _, c := range children {
|
||||
ids = append(ids, c)
|
||||
queue = append(queue, c)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// Search 搜索笔记(按标题和内容)
|
||||
@@ -170,7 +290,7 @@ func (r *NoteRepository) Search(keyword string, page, pageSize int) ([]model.Not
|
||||
}
|
||||
|
||||
offset := (page - 1) * pageSize
|
||||
err := query.Select("id, title, category, tags, is_pinned, is_favorite, parent_id, is_folder, sort_order, created_at, updated_at").
|
||||
err := query.Select("id, title, category, tags, is_pinned, is_favorite, is_public, parent_id, is_folder, sort_order, visit_count, created_at, updated_at").
|
||||
Order("is_pinned DESC, updated_at DESC").
|
||||
Offset(offset).
|
||||
Limit(pageSize).
|
||||
@@ -179,7 +299,7 @@ func (r *NoteRepository) Search(keyword string, page, pageSize int) ([]model.Not
|
||||
return items, total, err
|
||||
}
|
||||
|
||||
// GetCategories 获取所有分类
|
||||
// GetCategories 获取所有分类(排除已删除和目录)
|
||||
func (r *NoteRepository) GetCategories() ([]string, error) {
|
||||
var categories []string
|
||||
err := r.db.Model(&model.Note{}).
|
||||
@@ -189,7 +309,7 @@ func (r *NoteRepository) GetCategories() ([]string, error) {
|
||||
return categories, err
|
||||
}
|
||||
|
||||
// GetTags 获取所有标签
|
||||
// GetTags 获取所有标签(排除已删除和目录)
|
||||
func (r *NoteRepository) GetTags() ([]string, error) {
|
||||
var tagsJSON []string
|
||||
err := r.db.Model(&model.Note{}).
|
||||
@@ -210,3 +330,69 @@ func (r *NoteRepository) GetTags() ([]string, error) {
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ─────────────── 版本历史 ───────────────
|
||||
|
||||
// SaveVersion 保存笔记新版本快照
|
||||
func (r *NoteRepository) SaveVersion(note *model.Note) (*model.NoteVersion, error) {
|
||||
v := &model.NoteVersion{
|
||||
NoteID: note.ID,
|
||||
Title: note.Title,
|
||||
Content: note.Content,
|
||||
Category: note.Category,
|
||||
Tags: note.Tags,
|
||||
}
|
||||
if err := r.db.Create(v).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// ListVersions 获取笔记的所有版本(按时间倒序)
|
||||
func (r *NoteRepository) ListVersions(noteID uint) ([]model.NoteVersion, error) {
|
||||
var versions []model.NoteVersion
|
||||
err := r.db.Where("note_id = ?", noteID).Order("created_at DESC").Find(&versions).Error
|
||||
return versions, err
|
||||
}
|
||||
|
||||
// GetVersion 获取指定版本
|
||||
func (r *NoteRepository) GetVersion(id uint) (*model.NoteVersion, error) {
|
||||
var v model.NoteVersion
|
||||
err := r.db.First(&v, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
// DeleteVersions 删除某笔记的全部版本
|
||||
func (r *NoteRepository) DeleteVersions(noteID uint) error {
|
||||
return r.db.Where("note_id = ?", noteID).Delete(&model.NoteVersion{}).Error
|
||||
}
|
||||
|
||||
// ─────────────── 分享 ───────────────
|
||||
|
||||
// GetByShareToken 通过分享令牌获取笔记(含已删除?分享的笔记不应是已删除的,排除)
|
||||
func (r *NoteRepository) GetByShareToken(token string) (*model.Note, error) {
|
||||
var note model.Note
|
||||
err := r.db.Where("share_token = ?", token).First(¬e).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("分享链接无效或已失效")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return ¬e, nil
|
||||
}
|
||||
|
||||
// IncrementVisit 增加浏览次数
|
||||
func (r *NoteRepository) IncrementVisit(id uint) error {
|
||||
return r.db.Model(&model.Note{}).Where("id = ?", id).
|
||||
UpdateColumn("visit_count", gorm.Expr("visit_count + 1")).Error
|
||||
}
|
||||
|
||||
// IncrementVisitByToken 通过分享令牌增加浏览次数
|
||||
func (r *NoteRepository) IncrementVisitByToken(token string) error {
|
||||
return r.db.Model(&model.Note{}).Where("share_token = ?", token).
|
||||
UpdateColumn("visit_count", gorm.Expr("visit_count + 1")).Error
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user