Files
note-manager/handler/note_handler.go
T
Your Name 74fa759274 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
2026-08-11 11:21:29 +08:00

664 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"
"note-manager/service"
)
// NoteHandler 笔记请求处理器
type NoteHandler struct {
svc *service.NoteService
}
// NewNoteHandler 创建处理器实例
func NewNoteHandler(svc *service.NoteService) *NoteHandler {
return &NoteHandler{svc: svc}
}
// Response 通用响应结构
type Response struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
}
// PageResponse 分页响应结构
type PageResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
TotalPages int `json:"total_pages"`
}
func success(c *gin.Context, data interface{}) {
c.JSON(http.StatusOK, Response{Code: 0, Message: "success", Data: data})
}
func fail(c *gin.Context, status int, msg string) {
c.JSON(status, Response{Code: -1, Message: msg})
}
// CreateNote 创建笔记
func (h *NoteHandler) CreateNote(c *gin.Context) {
var req model.NoteCreateRequest
if err := c.ShouldBindJSON(&req); err != nil {
fail(c, http.StatusBadRequest, "请求参数错误: "+err.Error())
return
}
note, err := h.svc.CreateNote(req)
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, note)
}
// GetNote 获取笔记详情(公开只读接口)
// 安全策略:仅允许返回「公开且无密码」的笔记。有密码或未公开的笔记一律返回需授权提示,
// 防止通过该接口绕过密码保护读取内容。
func (h *NoteHandler) GetNote(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
}
// 公开接口白名单:仅公开且无密码的笔记返回完整内容
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)
}
// AccessNote 验证密码后获取笔记内容
func (h *NoteHandler) AccessNote(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID")
return
}
var req struct {
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&req); err != nil {
// 没有密码参数,尝试从 URL 参数获取
req.Password = c.Query("password")
}
note, upgrade, err := h.svc.GetNoteContent(uint(id), req.Password)
if err != nil {
if err.Error() == "密码错误" {
fail(c, http.StatusUnauthorized, err.Error())
return
}
fail(c, http.StatusNotFound, err.Error())
return
}
// 若旧 SHA-256 哈希命中,自动升级为 bcrypt
if upgrade {
_ = h.svc.UpgradePasswordHash(note.ID, req.Password)
}
success(c, note)
}
// UpdateNote 更新笔记
func (h *NoteHandler) UpdateNote(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID")
return
}
var req model.NoteUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
fail(c, http.StatusBadRequest, "请求参数错误: "+err.Error())
return
}
note, err := h.svc.UpdateNote(uint(id), req)
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, note)
}
// DeleteNote 删除笔记(软删除,进入回收站)
func (h *NoteHandler) DeleteNote(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.DeleteNote(uint(id)); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, nil)
}
// ListNotes 获取笔记列表
func (h *NoteHandler) ListNotes(c *gin.Context) {
var pinned *bool
if v := c.Query("pinned"); v != "" {
b := v == "true" || v == "1"
pinned = &b
}
var favorite *bool
if v := c.Query("favorite"); v != "" {
b := v == "true" || v == "1"
favorite = &b
}
items, total, totalPages, err := h.svc.ListNotes(
c.DefaultQuery("page", "1"),
c.DefaultQuery("page_size", ""),
c.Query("category"),
c.Query("tag"),
pinned,
favorite,
)
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
page := parseIntDefault(c.DefaultQuery("page", "1"), 1)
pageSize := parseIntDefault(c.DefaultQuery("page_size", "20"), 20)
c.JSON(http.StatusOK, PageResponse{
Code: 0,
Message: "success",
Data: items,
Total: total,
Page: page,
PageSize: pageSize,
TotalPages: totalPages,
})
}
// SearchNotes 搜索笔记
func (h *NoteHandler) SearchNotes(c *gin.Context) {
keyword := c.Query("q")
items, total, totalPages, err := h.svc.SearchNotes(
keyword,
c.DefaultQuery("page", "1"),
c.DefaultQuery("page_size", ""),
)
if err != nil {
fail(c, http.StatusBadRequest, err.Error())
return
}
page := parseIntDefault(c.DefaultQuery("page", "1"), 1)
pageSize := parseIntDefault(c.DefaultQuery("page_size", "20"), 20)
c.JSON(http.StatusOK, PageResponse{
Code: 0,
Message: "success",
Data: items,
Total: total,
Page: page,
PageSize: pageSize,
TotalPages: totalPages,
})
}
// GetCategories 获取分类列表
func (h *NoteHandler) GetCategories(c *gin.Context) {
categories, err := h.svc.GetCategories()
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, categories)
}
// GetTags 获取所有标签
func (h *NoteHandler) GetTags(c *gin.Context) {
tags, err := h.svc.GetTags()
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, tags)
}
// GetTree 获取树形结构(管理后台用)
func (h *NoteHandler) GetTree(c *gin.Context) {
tree, err := h.svc.GetAllTree()
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, tree)
}
// GetPublicTree 获取公开树形结构(前台用)
func (h *NoteHandler) GetPublicTree(c *gin.Context) {
tree, err := h.svc.GetPublicTree()
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, tree)
}
func parseIntDefault(s string, defaultVal int) int {
v, err := strconv.Atoi(s)
if err != nil {
return defaultVal
}
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) {
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
}
// 构造 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
}
// 构建节点 map 与根节点列表
type node struct {
item model.NoteListItem
children []*node
}
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)
}
}
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 文件
func (h *NoteHandler) ImportNotes(c *gin.Context) {
file, err := c.FormFile("file")
if err != nil {
fail(c, http.StatusBadRequest, "请选择要导入的文件")
return
}
if !strings.HasSuffix(strings.ToLower(file.Filename), ".md") {
fail(c, http.StatusBadRequest, "仅支持导入 .md 文件")
return
}
// 读取文件内容
src, err := file.Open()
if err != nil {
fail(c, http.StatusInternalServerError, "读取文件失败")
return
}
defer src.Close()
contentBytes, err := io.ReadAll(src)
if err != nil {
fail(c, http.StatusInternalServerError, "读取文件失败")
return
}
// 解析 front matter
title, body := parseFrontMatter(string(contentBytes))
if title == "" {
title = strings.TrimSuffix(file.Filename, ".md")
}
// 创建笔记
req := model.NoteCreateRequest{
Title: title,
Content: body,
}
note, err := h.svc.CreateNote(req)
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, note)
}
// parseFrontMatter 解析 YAML front matter
func parseFrontMatter(content string) (title string, body string) {
if !strings.HasPrefix(content, "---") {
return "", content
}
parts := strings.SplitN(content, "---", 3)
if len(parts) < 3 {
return "", content
}
frontMatter := parts[1]
body = strings.TrimSpace(parts[2])
// 解析 title
for _, line := range strings.Split(frontMatter, "\n") {
if strings.HasPrefix(line, "title:") {
title = strings.TrimSpace(strings.TrimPrefix(line, "title:"))
break
}
}
return title, body
}
// urlEncode URL 编码(RFC 3986
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
}