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:
+190
-17
@@ -1,10 +1,14 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"note-manager/model"
|
||||
"note-manager/repository"
|
||||
)
|
||||
@@ -23,13 +27,13 @@ func NewNoteService(repo *repository.NoteRepository, pageSize int) *NoteService
|
||||
// CreateNote 创建笔记或目录
|
||||
func (s *NoteService) CreateNote(req model.NoteCreateRequest) (*model.Note, error) {
|
||||
note := &model.Note{
|
||||
Title: req.Title,
|
||||
Content: req.Content,
|
||||
Category: req.Category,
|
||||
Tags: req.Tags,
|
||||
IsFolder: req.IsFolder,
|
||||
Title: req.Title,
|
||||
Content: req.Content,
|
||||
Category: req.Category,
|
||||
Tags: req.Tags,
|
||||
IsFolder: req.IsFolder,
|
||||
SortOrder: req.SortOrder,
|
||||
IsPublic: true, // 默认公开
|
||||
IsPublic: true, // 默认公开
|
||||
}
|
||||
if req.Password != "" {
|
||||
note.Password = model.HashPassword(req.Password)
|
||||
@@ -50,40 +54,52 @@ func (s *NoteService) CreateNote(req model.NoteCreateRequest) (*model.Note, erro
|
||||
if err := s.repo.Create(note); err != nil {
|
||||
return nil, fmt.Errorf("创建笔记失败: %w", err)
|
||||
}
|
||||
// 创建时保存第一版历史
|
||||
_, _ = s.repo.SaveVersion(note)
|
||||
return note, nil
|
||||
}
|
||||
|
||||
// GetNote 获取单条笔记
|
||||
// GetNote 获取单条笔记(后台管理使用,任意笔记)
|
||||
func (s *NoteService) GetNote(id uint) (*model.Note, error) {
|
||||
note, err := s.repo.GetByID(id)
|
||||
if err != nil {
|
||||
return nil, errors.New("笔记不存在")
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("笔记不存在")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return note, nil
|
||||
}
|
||||
|
||||
// GetNoteContent 获取笔记内容(需要密码验证)
|
||||
func (s *NoteService) GetNoteContent(id uint, password string) (*model.Note, error) {
|
||||
// GetNoteContent 获取笔记内容(需密码验证,用于前台展示)
|
||||
// 返回 (note, 是否需要升级密码哈希, err)
|
||||
func (s *NoteService) GetNoteContent(id uint, password string) (*model.Note, bool, error) {
|
||||
note, err := s.repo.GetByID(id)
|
||||
if err != nil {
|
||||
return nil, errors.New("笔记不存在")
|
||||
return nil, false, errors.New("笔记不存在")
|
||||
}
|
||||
// 检查密码
|
||||
if note.Password != "" {
|
||||
if !model.CheckPassword(password, note.Password) {
|
||||
return nil, errors.New("密码错误")
|
||||
ok, upgrade := model.CheckPassword(password, note.Password)
|
||||
if !ok {
|
||||
return nil, false, errors.New("密码错误")
|
||||
}
|
||||
return note, upgrade, nil
|
||||
}
|
||||
return note, nil
|
||||
return note, false, nil
|
||||
}
|
||||
|
||||
// UpdateNote 更新笔记或目录
|
||||
// UpdateNote 更新笔记或目录(保存更新前快照到版本历史)
|
||||
func (s *NoteService) UpdateNote(id uint, req model.NoteUpdateRequest) (*model.Note, error) {
|
||||
note, err := s.repo.GetByID(id)
|
||||
if err != nil {
|
||||
return nil, errors.New("笔记不存在")
|
||||
}
|
||||
|
||||
// 记录旧状态,判断是否产生实质内容变化
|
||||
oldContent := note.Content
|
||||
oldTitle := note.Title
|
||||
|
||||
if req.Title != nil {
|
||||
note.Title = *req.Title
|
||||
}
|
||||
@@ -116,17 +132,22 @@ func (s *NoteService) UpdateNote(id uint, req model.NoteUpdateRequest) (*model.N
|
||||
}
|
||||
if req.RemovePassword != nil && *req.RemovePassword {
|
||||
note.Password = ""
|
||||
} else if req.Password != nil {
|
||||
} else if req.Password != nil && *req.Password != "" {
|
||||
note.Password = model.HashPassword(*req.Password)
|
||||
}
|
||||
|
||||
if err := s.repo.Update(note); err != nil {
|
||||
return nil, fmt.Errorf("更新笔记失败: %w", err)
|
||||
}
|
||||
|
||||
// 若内容或标题发生变化,保存历史版本
|
||||
if oldContent != note.Content || oldTitle != note.Title {
|
||||
_, _ = s.repo.SaveVersion(note)
|
||||
}
|
||||
return note, nil
|
||||
}
|
||||
|
||||
// DeleteNote 删除笔记或目录(目录会删除所有子项)
|
||||
// DeleteNote 软删除笔记或目录(目录会软删除所有子项)
|
||||
func (s *NoteService) DeleteNote(id uint) error {
|
||||
note, err := s.repo.GetByID(id)
|
||||
if err != nil {
|
||||
@@ -217,6 +238,158 @@ func (s *NoteService) GetTags() ([]string, error) {
|
||||
return s.repo.GetTags()
|
||||
}
|
||||
|
||||
// ─────────────── 回收站 ───────────────
|
||||
|
||||
// ListTrash 获取回收站列表
|
||||
func (s *NoteService) ListTrash() ([]model.NoteListItem, error) {
|
||||
return s.repo.ListTrash()
|
||||
}
|
||||
|
||||
// RestoreNote 从回收站恢复笔记或目录(整棵子树)
|
||||
func (s *NoteService) RestoreNote(id uint) error {
|
||||
note, err := s.repo.GetByIDIncludingDeleted(id)
|
||||
if err != nil {
|
||||
return errors.New("记录不存在")
|
||||
}
|
||||
if note.IsFolder {
|
||||
return s.repo.RestoreSubtree(id)
|
||||
}
|
||||
return s.repo.Restore(id)
|
||||
}
|
||||
|
||||
// PurgeNote 彻底删除笔记或目录(不可恢复)
|
||||
func (s *NoteService) PurgeNote(id uint) error {
|
||||
note, err := s.repo.GetByIDIncludingDeleted(id)
|
||||
if err != nil {
|
||||
return errors.New("记录不存在")
|
||||
}
|
||||
if note.IsFolder {
|
||||
if err := s.repo.HardDeleteWithChildren(id); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := s.repo.HardDelete(id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// 清理版本历史
|
||||
_ = s.repo.DeleteVersions(id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// EmptyTrash 清空回收站
|
||||
func (s *NoteService) EmptyTrash() error {
|
||||
trash, err := s.repo.ListTrash()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range trash {
|
||||
if err := s.PurgeNote(item.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ─────────────── 版本历史 ───────────────
|
||||
|
||||
// ListVersions 获取笔记版本列表
|
||||
func (s *NoteService) ListVersions(noteID uint) ([]model.NoteVersion, error) {
|
||||
return s.repo.ListVersions(noteID)
|
||||
}
|
||||
|
||||
// RestoreVersion 将笔记恢复到指定版本
|
||||
func (s *NoteService) RestoreVersion(noteID, versionID uint) (*model.Note, error) {
|
||||
version, err := s.repo.GetVersion(versionID)
|
||||
if err != nil {
|
||||
return nil, errors.New("版本不存在")
|
||||
}
|
||||
note, err := s.repo.GetByID(noteID)
|
||||
if err != nil {
|
||||
return nil, errors.New("笔记不存在")
|
||||
}
|
||||
// 保存当前状态为历史版本(防止覆盖)
|
||||
_, _ = s.repo.SaveVersion(note)
|
||||
// 回滚
|
||||
note.Title = version.Title
|
||||
note.Content = version.Content
|
||||
note.Category = version.Category
|
||||
note.Tags = version.Tags
|
||||
if err := s.repo.Update(note); err != nil {
|
||||
return nil, fmt.Errorf("恢复版本失败: %w", err)
|
||||
}
|
||||
return note, nil
|
||||
}
|
||||
|
||||
// ─────────────── 分享 ───────────────
|
||||
|
||||
// CreateShare 创建/更新分享令牌
|
||||
func (s *NoteService) CreateShare(noteID uint, expireHours int) (*model.Note, error) {
|
||||
note, err := s.repo.GetByID(noteID)
|
||||
if err != nil {
|
||||
return nil, errors.New("笔记不存在")
|
||||
}
|
||||
if note.IsFolder {
|
||||
return nil, errors.New("目录不能分享")
|
||||
}
|
||||
note.ShareToken = randomToken(32)
|
||||
if expireHours > 0 {
|
||||
t := time.Now().Add(time.Duration(expireHours) * time.Hour)
|
||||
note.ShareExpireAt = &t
|
||||
} else {
|
||||
note.ShareExpireAt = nil
|
||||
}
|
||||
if err := s.repo.Update(note); err != nil {
|
||||
return nil, fmt.Errorf("创建分享失败: %w", err)
|
||||
}
|
||||
return note, nil
|
||||
}
|
||||
|
||||
// RevokeShare 撤销分享
|
||||
func (s *NoteService) RevokeShare(noteID uint) error {
|
||||
note, err := s.repo.GetByID(noteID)
|
||||
if err != nil {
|
||||
return errors.New("笔记不存在")
|
||||
}
|
||||
note.ShareToken = ""
|
||||
note.ShareExpireAt = nil
|
||||
return s.repo.Update(note)
|
||||
}
|
||||
|
||||
// GetSharedNote 通过令牌获取分享笔记(校验过期时间)
|
||||
func (s *NoteService) GetSharedNote(token string) (*model.Note, error) {
|
||||
note, err := s.repo.GetByShareToken(token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if note.ShareExpireAt != nil && time.Now().After(*note.ShareExpireAt) {
|
||||
return nil, errors.New("分享链接已过期")
|
||||
}
|
||||
// 若有密码则需校验(在 handler 层处理)
|
||||
_ = s.repo.IncrementVisitByToken(token)
|
||||
return note, nil
|
||||
}
|
||||
|
||||
// UpgradePasswordHash 将旧 SHA-256 密码哈希升级为 bcrypt
|
||||
func (s *NoteService) UpgradePasswordHash(id uint, password string) error {
|
||||
note, err := s.repo.GetByID(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
note.Password = model.HashPassword(password)
|
||||
return s.repo.Update(note)
|
||||
}
|
||||
|
||||
// randomToken 生成安全的随机令牌
|
||||
func randomToken(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)
|
||||
}
|
||||
|
||||
func parseInt(s string, defaultVal int) int {
|
||||
if s == "" {
|
||||
return defaultVal
|
||||
|
||||
Reference in New Issue
Block a user