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
+309 -54
View File
@@ -1,11 +1,16 @@
package handler
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"note-manager/model"
@@ -48,22 +53,8 @@ func fail(c *gin.Context, status int, msg string) {
c.JSON(status, Response{Code: -1, Message: msg})
}
// requireAuth 需要管理员权限
func requireAuth(c *gin.Context) bool {
token, err := c.Cookie("admin_token")
if err != nil || token != "authenticated" {
c.JSON(http.StatusUnauthorized, Response{Code: 401, Message: "请先登录后台管理"})
return false
}
return true
}
// CreateNote 创建笔记
func (h *NoteHandler) CreateNote(c *gin.Context) {
if !requireAuth(c) {
return
}
var req model.NoteCreateRequest
if err := c.ShouldBindJSON(&req); err != nil {
fail(c, http.StatusBadRequest, "请求参数错误: "+err.Error())
@@ -79,7 +70,9 @@ func (h *NoteHandler) CreateNote(c *gin.Context) {
success(c, note)
}
// GetNote 获取笔记详情
// GetNote 获取笔记详情(公开只读接口)
// 安全策略:仅允许返回「公开且无密码」的笔记。有密码或未公开的笔记一律返回需授权提示,
// 防止通过该接口绕过密码保护读取内容。
func (h *NoteHandler) GetNote(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
@@ -93,6 +86,29 @@ func (h *NoteHandler) GetNote(c *gin.Context) {
return
}
// 公开接口白名单:仅公开且无密码的笔记返回完整内容
if !note.IsPublic || note.Password != "" {
fail(c, http.StatusForbidden, "该笔记受保护,无法直接访问")
return
}
success(c, note)
}
// GetAdminNote 获取笔记详情(管理后台用,返回完整内容)
func (h *NoteHandler) GetAdminNote(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID")
return
}
note, err := h.svc.GetNote(uint(id))
if err != nil {
fail(c, http.StatusNotFound, err.Error())
return
}
success(c, note)
}
@@ -112,7 +128,7 @@ func (h *NoteHandler) AccessNote(c *gin.Context) {
req.Password = c.Query("password")
}
note, err := h.svc.GetNoteContent(uint(id), req.Password)
note, upgrade, err := h.svc.GetNoteContent(uint(id), req.Password)
if err != nil {
if err.Error() == "密码错误" {
fail(c, http.StatusUnauthorized, err.Error())
@@ -122,15 +138,16 @@ func (h *NoteHandler) AccessNote(c *gin.Context) {
return
}
// 若旧 SHA-256 哈希命中,自动升级为 bcrypt
if upgrade {
_ = h.svc.UpgradePasswordHash(note.ID, req.Password)
}
success(c, note)
}
// UpdateNote 更新笔记
func (h *NoteHandler) UpdateNote(c *gin.Context) {
if !requireAuth(c) {
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID")
@@ -152,12 +169,8 @@ func (h *NoteHandler) UpdateNote(c *gin.Context) {
success(c, note)
}
// DeleteNote 删除笔记
// DeleteNote 删除笔记(软删除,进入回收站)
func (h *NoteHandler) DeleteNote(c *gin.Context) {
if !requireAuth(c) {
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID")
@@ -287,10 +300,168 @@ func parseIntDefault(s string, defaultVal int) int {
return v
}
// ─────────────── 回收站 ───────────────
// ListTrash 回收站列表
func (h *NoteHandler) ListTrash(c *gin.Context) {
items, err := h.svc.ListTrash()
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, items)
}
// RestoreNote 恢复笔记
func (h *NoteHandler) RestoreNote(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID")
return
}
if err := h.svc.RestoreNote(uint(id)); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, nil)
}
// PurgeNote 彻底删除笔记
func (h *NoteHandler) PurgeNote(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID")
return
}
if err := h.svc.PurgeNote(uint(id)); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, nil)
}
// EmptyTrash 清空回收站
func (h *NoteHandler) EmptyTrash(c *gin.Context) {
if err := h.svc.EmptyTrash(); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, nil)
}
// ─────────────── 版本历史 ───────────────
// ListVersions 版本列表
func (h *NoteHandler) ListVersions(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID")
return
}
versions, err := h.svc.ListVersions(uint(id))
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, versions)
}
// RestoreVersion 恢复到指定版本
func (h *NoteHandler) RestoreVersion(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID")
return
}
versionID, err := strconv.ParseUint(c.PostForm("version_id"), 10, 64)
if err != nil {
fail(c, http.StatusBadRequest, "无效的版本 ID")
return
}
note, err := h.svc.RestoreVersion(uint(id), uint(versionID))
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, note)
}
// ─────────────── 分享 ───────────────
// CreateShare 创建分享
func (h *NoteHandler) CreateShare(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID")
return
}
expireHours, _ := strconv.Atoi(c.DefaultPostForm("expire_hours", "0"))
note, err := h.svc.CreateShare(uint(id), expireHours)
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, gin.H{
"id": note.ID,
"share_token": note.ShareToken,
"url": "/share/" + note.ShareToken,
"expire_at": note.ShareExpireAt,
})
}
// RevokeShare 撤销分享
func (h *NoteHandler) RevokeShare(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID")
return
}
if err := h.svc.RevokeShare(uint(id)); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, nil)
}
// GetSharedNote 通过令牌获取分享笔记的 JSON 接口
// 分享的笔记若设置了访问密码,则需通过 password 参数/请求体校验
func (h *NoteHandler) GetSharedNote(c *gin.Context) {
token := c.Param("token")
var req struct {
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&req); err != nil {
req.Password = c.Query("password")
}
note, err := h.svc.GetSharedNote(token)
if err != nil {
fail(c, http.StatusNotFound, err.Error())
return
}
// 若分享的笔记有访问密码,校验
if note.Password != "" {
ok, _ := model.CheckPassword(req.Password, note.Password)
if !ok {
fail(c, http.StatusUnauthorized, "密码错误")
return
}
}
success(c, note)
}
// SharePage 分享阅读页(简单 HTML,展示分享的笔记内容)
func (h *NoteHandler) SharePage(c *gin.Context) {
c.HTML(http.StatusOK, "share.html", gin.H{
"token": c.Param("token"),
})
}
// ExportNote 导出笔记为 Markdown 文件
func (h *NoteHandler) ExportNote(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID")
return
@@ -302,55 +473,103 @@ func (h *NoteHandler) ExportNote(c *gin.Context) {
return
}
// 如果是目录,导出目录下所有笔记
if note.IsFolder {
fail(c, http.StatusBadRequest, "不支持导出目录,请选择具体笔记")
// 构造 Markdown(带 YAML front matter
content := buildMarkdown(note)
filename := sanitizeFileName(note.Title) + ".md"
c.Header("Content-Disposition", "attachment; filename=\""+filename+"\"")
c.Header("Content-Type", "text/markdown; charset=utf-8")
c.String(http.StatusOK, content)
}
// ExportAll 批量导出全部笔记为 zip
func (h *NoteHandler) ExportAll(c *gin.Context) {
tree, err := h.svc.GetAllTree()
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
// 设置下载头
filename := note.Title + ".md"
c.Header("Content-Disposition", "attachment; filename*=UTF-8''"+urlEncode(filename))
c.Header("Content-Type", "text/markdown; charset=utf-8")
// 添加 front matter
frontMatter := "---\n"
frontMatter += "title: " + note.Title + "\n"
if note.Category != "" {
frontMatter += "category: " + note.Category + "\n"
// 构建节点 map 与根节点列表
type node struct {
item model.NoteListItem
children []*node
}
if note.Tags != "" {
frontMatter += "tags: " + note.Tags + "\n"
nodeMap := make(map[uint]*node)
for i := range tree {
nodeMap[tree[i].ID] = &node{item: tree[i]}
}
var roots []*node
for i := range tree {
n := nodeMap[tree[i].ID]
if tree[i].ParentID == 0 {
roots = append(roots, n)
} else if p, ok := nodeMap[tree[i].ParentID]; ok {
p.children = append(p.children, n)
} else {
roots = append(roots, n)
}
}
frontMatter += "---\n\n"
c.String(http.StatusOK, frontMatter+note.Content)
c.Header("Content-Type", "application/zip")
c.Header("Content-Disposition", "attachment; filename=notes-export.zip")
zw := zip.NewWriter(c.Writer)
defer zw.Close()
var zipNotes func(n *node, path string) error
zipNotes = func(n *node, path string) error {
if n.item.IsFolder {
dirPath := filepath.Join(path, sanitizeFileName(n.item.Title))
for _, ch := range n.children {
if err := zipNotes(ch, dirPath); err != nil {
return err
}
}
return nil
}
note, err := h.svc.GetNote(n.item.ID)
if err != nil {
return err
}
body := buildMarkdown(note)
fname := filepath.Join(path, sanitizeFileName(note.Title)+".md")
fw, err := zw.Create(fname)
if err != nil {
return err
}
_, err = fw.Write([]byte(body))
return err
}
for _, root := range roots {
if err := zipNotes(root, ""); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
}
}
// ImportNotes 导入 Markdown 文件创建笔记
// ImportNotes 导入 Markdown 文件
func (h *NoteHandler) ImportNotes(c *gin.Context) {
file, err := c.FormFile("file")
if err != nil {
fail(c, http.StatusBadRequest, "请选择文件")
fail(c, http.StatusBadRequest, "请选择要导入的文件")
return
}
// 验证文件类型
if file.Header.Get("Content-Type") != "text/markdown" &&
!strings.HasSuffix(file.Filename, ".md") {
fail(c, http.StatusBadRequest, "仅支持 .md 文件")
if !strings.HasSuffix(strings.ToLower(file.Filename), ".md") {
fail(c, http.StatusBadRequest, "仅支持导入 .md 文件")
return
}
// 读取文件内容
f, err := file.Open()
src, err := file.Open()
if err != nil {
fail(c, http.StatusInternalServerError, "读取文件失败")
return
}
defer f.Close()
contentBytes, err := io.ReadAll(f)
defer src.Close()
contentBytes, err := io.ReadAll(src)
if err != nil {
fail(c, http.StatusInternalServerError, "读取文件失败")
return
@@ -406,3 +625,39 @@ func parseFrontMatter(content string) (title string, body string) {
func urlEncode(s string) string {
return url.QueryEscape(s)
}
// buildMarkdown 将笔记构造为带 YAML front matter 的 Markdown
func buildMarkdown(note *model.Note) string {
var b strings.Builder
b.WriteString("---\n")
b.WriteString("title: " + note.Title + "\n")
if note.Category != "" {
b.WriteString("category: " + note.Category + "\n")
}
// tags 是 JSON 数组字符串,转成 YAML 列表
if note.Tags != "" {
var tags []string
if err := json.Unmarshal([]byte(note.Tags), &tags); err == nil && len(tags) > 0 {
b.WriteString("tags:\n")
for _, t := range tags {
b.WriteString(" - " + t + "\n")
}
}
}
b.WriteString("created: " + note.CreatedAt.Format("2006-01-02 15:04:05") + "\n")
b.WriteString("updated: " + note.UpdatedAt.Format("2006-01-02 15:04:05") + "\n")
b.WriteString("---\n\n")
b.WriteString(note.Content)
return b.String()
}
// sanitizeFileName 清理文件名中的非法字符
func sanitizeFileName(name string) string {
replacer := strings.NewReplacer("/", "_", "\\", "_", ":", "_", "*", "_", "?", "_", "\"", "_", "<", "_", ">", "_", "|", "_")
name = replacer.Replace(name)
name = strings.TrimSpace(name)
if name == "" {
return fmt.Sprintf("note-%d", time.Now().Unix())
}
return name
}