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:
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"note-manager/config"
|
||||
"note-manager/middleware"
|
||||
"note-manager/service"
|
||||
)
|
||||
|
||||
@@ -19,7 +20,7 @@ func NewAdminHandler(noteSvc *service.NoteService, cfg *config.Config) *AdminHan
|
||||
return &AdminHandler{noteSvc: noteSvc, config: cfg}
|
||||
}
|
||||
|
||||
// Login 登录页面
|
||||
// LoginPage 登录页面
|
||||
func (h *AdminHandler) LoginPage(c *gin.Context) {
|
||||
c.HTML(http.StatusOK, "login.html", gin.H{
|
||||
"title": "后台管理登录",
|
||||
@@ -30,8 +31,11 @@ func (h *AdminHandler) LoginPage(c *gin.Context) {
|
||||
func (h *AdminHandler) Login(c *gin.Context) {
|
||||
password := c.PostForm("password")
|
||||
if password == h.config.AdminPass {
|
||||
// 设置 cookie,有效期 7 天
|
||||
c.SetCookie("admin_token", "authenticated", 7*24*3600, "/", "", false, true)
|
||||
// 生成随机会话 token
|
||||
token, _ := middleware.NewSessionToken()
|
||||
// 通过环境变量判断是否启用 HTTPS(生产建议配置)
|
||||
secure := c.Request.TLS != nil
|
||||
middleware.SetAuthCookie(c, token, secure)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
"message": "登录成功",
|
||||
@@ -46,7 +50,8 @@ func (h *AdminHandler) Login(c *gin.Context) {
|
||||
|
||||
// Logout 登出
|
||||
func (h *AdminHandler) Logout(c *gin.Context) {
|
||||
c.SetCookie("admin_token", "", -1, "/", "", false, true)
|
||||
middleware.RevokeSession(middleware.GetAuthToken(c))
|
||||
middleware.ClearAuthCookie(c)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
"message": "已退出登录",
|
||||
@@ -55,8 +60,8 @@ func (h *AdminHandler) Logout(c *gin.Context) {
|
||||
|
||||
// CheckAuth 检查是否已登录
|
||||
func (h *AdminHandler) CheckAuth(c *gin.Context) {
|
||||
token, err := c.Cookie("admin_token")
|
||||
if err == nil && token == "authenticated" {
|
||||
token := middleware.GetAuthToken(c)
|
||||
if middleware.IsValidSession(token) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
"message": "已登录",
|
||||
@@ -77,8 +82,8 @@ func (h *AdminHandler) CheckAuth(c *gin.Context) {
|
||||
|
||||
// IndexPage 后台管理首页
|
||||
func (h *AdminHandler) IndexPage(c *gin.Context) {
|
||||
token, err := c.Cookie("admin_token")
|
||||
if err != nil || token != "authenticated" {
|
||||
token := middleware.GetAuthToken(c)
|
||||
if !middleware.IsValidSession(token) {
|
||||
c.Redirect(http.StatusFound, "/admin/login")
|
||||
return
|
||||
}
|
||||
|
||||
+67
-26
@@ -1,6 +1,8 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -8,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gabriel-vasile/mimetype"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -26,6 +29,27 @@ func (h *ImageHandler) Init() error {
|
||||
return os.MkdirAll(h.uploadDir, 0755)
|
||||
}
|
||||
|
||||
// 允许的图片 MIME 类型(基于内容嗅探,而非仅扩展名)
|
||||
var allowedImageTypes = map[string]bool{
|
||||
"image/jpeg": true,
|
||||
"image/png": true,
|
||||
"image/gif": true,
|
||||
"image/webp": true,
|
||||
"image/bmp": true,
|
||||
"image/svg+xml": false, // SVG 可含脚本,默认禁止,避免 XSS
|
||||
"image/x-icon": false,
|
||||
}
|
||||
|
||||
// allowedExts 扩展名白名单(与内容嗅探双重校验)
|
||||
var allowedExts = map[string]bool{
|
||||
".jpg": true,
|
||||
".jpeg": true,
|
||||
".png": true,
|
||||
".gif": true,
|
||||
".webp": true,
|
||||
".bmp": true,
|
||||
}
|
||||
|
||||
// Upload 上传图片
|
||||
func (h *ImageHandler) Upload(c *gin.Context) {
|
||||
file, err := c.FormFile("image")
|
||||
@@ -34,19 +58,10 @@ func (h *ImageHandler) Upload(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 验证文件类型
|
||||
// 验证扩展名
|
||||
ext := strings.ToLower(filepath.Ext(file.Filename))
|
||||
allowedExts := map[string]bool{
|
||||
".jpg": true,
|
||||
".jpeg": true,
|
||||
".png": true,
|
||||
".gif": true,
|
||||
".webp": true,
|
||||
".bmp": true,
|
||||
}
|
||||
|
||||
if !allowedExts[ext] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "不支持的图片格式,仅支持 jpg、png、gif、webp"})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "不支持的图片格式,仅支持 jpg、png、gif、webp、bmp"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -56,34 +71,60 @@ func (h *ImageHandler) Upload(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 生成唯一文件名
|
||||
filename := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), randomString(8), ext)
|
||||
filepath := filepath.Join(h.uploadDir, filename)
|
||||
// 打开文件做内容嗅探,防止伪造扩展名上传恶意内容
|
||||
src, err := file.Open()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "读取文件失败"})
|
||||
return
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
head := make([]byte, 512)
|
||||
if _, err := src.Read(head); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "读取文件失败"})
|
||||
return
|
||||
}
|
||||
|
||||
mime := mimetype.Detect(head)
|
||||
if !allowedImageTypes[mime.String()] && mime.String() != "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "文件内容不是合法图片"})
|
||||
return
|
||||
}
|
||||
|
||||
// 生成唯一文件名(使用安全随机源)
|
||||
filename := fmt.Sprintf("%d_%s%s", osTimeNano(), secureRandomString(8), ext)
|
||||
targetPath := filepath.Join(h.uploadDir, filename)
|
||||
|
||||
// 保存文件
|
||||
if err := c.SaveUploadedFile(file, filepath); err != nil {
|
||||
if err := c.SaveUploadedFile(file, targetPath); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "保存图片失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 返回访问 URL
|
||||
url := "/uploads/" + filename
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
"message": "上传成功",
|
||||
"data": gin.H{
|
||||
"url": url,
|
||||
"url": "/uploads/" + filename,
|
||||
"mime": mime.String(),
|
||||
"width": 0,
|
||||
"height": 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// randomString 生成随机字符串
|
||||
func randomString(length int) string {
|
||||
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
result := make([]byte, length)
|
||||
for i := range result {
|
||||
result[i] = chars[time.Now().UnixNano()%int64(len(chars))]
|
||||
time.Sleep(time.Nanosecond)
|
||||
}
|
||||
return string(result)
|
||||
// osTimeNano 返回当前纳秒时间戳
|
||||
func osTimeNano() int64 {
|
||||
return time.Now().UnixNano()
|
||||
}
|
||||
|
||||
// secureRandomString 使用 crypto/rand 生成安全的随机十六进制字符串
|
||||
func secureRandomString(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)
|
||||
}
|
||||
|
||||
+309
-54
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user