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:
@@ -583,3 +583,76 @@ await fetch('/api/notes', {
|
|||||||
credentials: 'include'
|
credentials: 'include'
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 新增功能 API(v2)
|
||||||
|
|
||||||
|
## 管理接口认证(安全优化)
|
||||||
|
|
||||||
|
后台管理写操作(创建/更新/删除/回收站/版本/分享/导入导出/上传)统一走 `/admin/api`,
|
||||||
|
需登录后携带服务端会话 cookie(随机 token,非固定字符串)。未登录返回 401。
|
||||||
|
|
||||||
|
## 安全策略调整
|
||||||
|
|
||||||
|
### 公开接口 `GET /api/notes/:id`
|
||||||
|
仅返回 **公开且未设置密码** 的笔记完整内容。带密码或未公开的笔记返回 403(需走密码验证接口),
|
||||||
|
防止绕过密码保护直接读取内容。
|
||||||
|
|
||||||
|
## 回收站(软删除)
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET | `/admin/api/trash` | 回收站列表(含已删除的目录和笔记) |
|
||||||
|
| POST | `/admin/api/restore/:id` | 恢复笔记/目录(目录连子树一起恢复) |
|
||||||
|
| POST | `/admin/api/purge/:id` | 彻底删除(不可恢复,含版本历史) |
|
||||||
|
| POST | `/admin/api/empty-trash` | 清空回收站 |
|
||||||
|
|
||||||
|
删除笔记 `DELETE /admin/api/notes/:id` 现为软删除(进入回收站),目录删除会连带软删除所有子项。
|
||||||
|
|
||||||
|
## 版本历史
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET | `/admin/api/notes/:id/versions` | 获取笔记全部历史版本 |
|
||||||
|
| POST | `/admin/api/notes/:id/restore-version` | 恢复指定版本(form: `version_id`) |
|
||||||
|
|
||||||
|
每次保存(标题或内容变化)自动生成快照。
|
||||||
|
|
||||||
|
## 分享链接
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| POST | `/admin/api/notes/:id/share` | 创建分享(form: `expire_hours`,0=永久) |
|
||||||
|
| POST | `/admin/api/notes/:id/revoke-share` | 撤销分享 |
|
||||||
|
| GET | `/api/share/:token` | 公开获取分享笔记 JSON(有密码需带 `?password=`) |
|
||||||
|
| GET | `/share/:token` | 分享阅读页(HTML) |
|
||||||
|
|
||||||
|
## 批量导出
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET | `/admin/api/export-all` | 全部笔记导出为 zip(按目录结构 + YAML front matter) |
|
||||||
|
|
||||||
|
## 图片上传(安全增强)
|
||||||
|
|
||||||
|
上传会做**内容嗅探**(magic bytes 校验),不仅检查扩展名。伪装成图片的脚本会被拒绝。
|
||||||
|
|
||||||
|
## 后台管理笔记接口
|
||||||
|
|
||||||
|
管理端读取/写笔记统一走 `/admin/api/notes`(可访问私有、带密码笔记):
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| POST | `/admin/api/notes` | 创建 |
|
||||||
|
| GET | `/admin/api/notes/:id` | 详情(完整内容) |
|
||||||
|
| PUT | `/admin/api/notes/:id` | 更新 |
|
||||||
|
| DELETE | `/admin/api/notes/:id` | 软删除 |
|
||||||
|
|
||||||
|
## 前端新增功能
|
||||||
|
|
||||||
|
- **深色模式**:前台/后台均可切换(🌙/☀️ 按钮),偏好存 localStorage
|
||||||
|
- **Mermaid 图表**:前台 Markdown 中 ` ```mermaid ` 代码块渲染为流程图/时序图等
|
||||||
|
- **待办清单**:前台渲染 `- [ ]` / `- [x]` 可勾选清单
|
||||||
|
- **字数统计**:前台笔记详情显示字数与预估阅读时长
|
||||||
|
- **回收站/版本历史/分享**:后台工具栏按钮 + 面板
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Note Manager (Cloud Notes)
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
WorkingDirectory=/fs/1000/ftp/Project/note-manager
|
||||||
|
ExecStart=/fs/1000/ftp/Project/note-manager/dist/note-manager
|
||||||
|
Environment=PORT=8080
|
||||||
|
Environment=DB_PATH=/fs/1000/ftp/Project/note-manager/data/notes.db
|
||||||
|
Environment=UPLOAD_DIR=/fs/1000/ftp/Project/note-manager/uploads
|
||||||
|
Restart=always
|
||||||
|
RestartSec=3
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -1,13 +1,19 @@
|
|||||||
module note-manager
|
module note-manager
|
||||||
|
|
||||||
go 1.21.0
|
go 1.25.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.2
|
||||||
|
github.com/gin-gonic/gin v1.9.1
|
||||||
|
golang.org/x/crypto v0.54.0
|
||||||
|
gorm.io/driver/sqlite v1.5.6
|
||||||
|
gorm.io/gorm v1.25.7
|
||||||
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/bytedance/sonic v1.9.1 // indirect
|
github.com/bytedance/sonic v1.9.1 // indirect
|
||||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
|
||||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||||
github.com/gin-gonic/gin v1.9.1 // indirect
|
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.14.0 // indirect
|
github.com/go-playground/validator/v10 v10.14.0 // indirect
|
||||||
@@ -25,12 +31,9 @@ require (
|
|||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||||
golang.org/x/arch v0.3.0 // indirect
|
golang.org/x/arch v0.3.0 // indirect
|
||||||
golang.org/x/crypto v0.9.0 // indirect
|
golang.org/x/net v0.56.0 // indirect
|
||||||
golang.org/x/net v0.10.0 // indirect
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
golang.org/x/sys v0.8.0 // indirect
|
golang.org/x/text v0.40.0 // indirect
|
||||||
golang.org/x/text v0.9.0 // indirect
|
|
||||||
google.golang.org/protobuf v1.30.0 // indirect
|
google.golang.org/protobuf v1.30.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
gorm.io/driver/sqlite v1.5.6 // indirect
|
|
||||||
gorm.io/gorm v1.25.7 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -65,14 +65,22 @@ golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
|
|||||||
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||||
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
|
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
|
||||||
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
|
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
|
||||||
|
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||||
|
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||||
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
|
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
|
||||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||||
|
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||||
|
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||||
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
|
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
|
||||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
|
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
|
||||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
|
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||||
|
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
|
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"note-manager/config"
|
"note-manager/config"
|
||||||
|
"note-manager/middleware"
|
||||||
"note-manager/service"
|
"note-manager/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,7 +20,7 @@ func NewAdminHandler(noteSvc *service.NoteService, cfg *config.Config) *AdminHan
|
|||||||
return &AdminHandler{noteSvc: noteSvc, config: cfg}
|
return &AdminHandler{noteSvc: noteSvc, config: cfg}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Login 登录页面
|
// LoginPage 登录页面
|
||||||
func (h *AdminHandler) LoginPage(c *gin.Context) {
|
func (h *AdminHandler) LoginPage(c *gin.Context) {
|
||||||
c.HTML(http.StatusOK, "login.html", gin.H{
|
c.HTML(http.StatusOK, "login.html", gin.H{
|
||||||
"title": "后台管理登录",
|
"title": "后台管理登录",
|
||||||
@@ -30,8 +31,11 @@ func (h *AdminHandler) LoginPage(c *gin.Context) {
|
|||||||
func (h *AdminHandler) Login(c *gin.Context) {
|
func (h *AdminHandler) Login(c *gin.Context) {
|
||||||
password := c.PostForm("password")
|
password := c.PostForm("password")
|
||||||
if password == h.config.AdminPass {
|
if password == h.config.AdminPass {
|
||||||
// 设置 cookie,有效期 7 天
|
// 生成随机会话 token
|
||||||
c.SetCookie("admin_token", "authenticated", 7*24*3600, "/", "", false, true)
|
token, _ := middleware.NewSessionToken()
|
||||||
|
// 通过环境变量判断是否启用 HTTPS(生产建议配置)
|
||||||
|
secure := c.Request.TLS != nil
|
||||||
|
middleware.SetAuthCookie(c, token, secure)
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"code": 0,
|
"code": 0,
|
||||||
"message": "登录成功",
|
"message": "登录成功",
|
||||||
@@ -46,7 +50,8 @@ func (h *AdminHandler) Login(c *gin.Context) {
|
|||||||
|
|
||||||
// Logout 登出
|
// Logout 登出
|
||||||
func (h *AdminHandler) Logout(c *gin.Context) {
|
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{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"code": 0,
|
"code": 0,
|
||||||
"message": "已退出登录",
|
"message": "已退出登录",
|
||||||
@@ -55,8 +60,8 @@ func (h *AdminHandler) Logout(c *gin.Context) {
|
|||||||
|
|
||||||
// CheckAuth 检查是否已登录
|
// CheckAuth 检查是否已登录
|
||||||
func (h *AdminHandler) CheckAuth(c *gin.Context) {
|
func (h *AdminHandler) CheckAuth(c *gin.Context) {
|
||||||
token, err := c.Cookie("admin_token")
|
token := middleware.GetAuthToken(c)
|
||||||
if err == nil && token == "authenticated" {
|
if middleware.IsValidSession(token) {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"code": 0,
|
"code": 0,
|
||||||
"message": "已登录",
|
"message": "已登录",
|
||||||
@@ -77,8 +82,8 @@ func (h *AdminHandler) CheckAuth(c *gin.Context) {
|
|||||||
|
|
||||||
// IndexPage 后台管理首页
|
// IndexPage 后台管理首页
|
||||||
func (h *AdminHandler) IndexPage(c *gin.Context) {
|
func (h *AdminHandler) IndexPage(c *gin.Context) {
|
||||||
token, err := c.Cookie("admin_token")
|
token := middleware.GetAuthToken(c)
|
||||||
if err != nil || token != "authenticated" {
|
if !middleware.IsValidSession(token) {
|
||||||
c.Redirect(http.StatusFound, "/admin/login")
|
c.Redirect(http.StatusFound, "/admin/login")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
+67
-26
@@ -1,6 +1,8 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -8,6 +10,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/gabriel-vasile/mimetype"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -26,6 +29,27 @@ func (h *ImageHandler) Init() error {
|
|||||||
return os.MkdirAll(h.uploadDir, 0755)
|
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 上传图片
|
// Upload 上传图片
|
||||||
func (h *ImageHandler) Upload(c *gin.Context) {
|
func (h *ImageHandler) Upload(c *gin.Context) {
|
||||||
file, err := c.FormFile("image")
|
file, err := c.FormFile("image")
|
||||||
@@ -34,19 +58,10 @@ func (h *ImageHandler) Upload(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证文件类型
|
// 验证扩展名
|
||||||
ext := strings.ToLower(filepath.Ext(file.Filename))
|
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] {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,34 +71,60 @@ func (h *ImageHandler) Upload(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成唯一文件名
|
// 打开文件做内容嗅探,防止伪造扩展名上传恶意内容
|
||||||
filename := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), randomString(8), ext)
|
src, err := file.Open()
|
||||||
filepath := filepath.Join(h.uploadDir, filename)
|
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": "保存图片失败"})
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "保存图片失败"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 返回访问 URL
|
// 返回访问 URL
|
||||||
url := "/uploads/" + filename
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"code": 0,
|
"code": 0,
|
||||||
"message": "上传成功",
|
"message": "上传成功",
|
||||||
"data": gin.H{
|
"data": gin.H{
|
||||||
"url": url,
|
"url": "/uploads/" + filename,
|
||||||
|
"mime": mime.String(),
|
||||||
|
"width": 0,
|
||||||
|
"height": 0,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// randomString 生成随机字符串
|
// osTimeNano 返回当前纳秒时间戳
|
||||||
func randomString(length int) string {
|
func osTimeNano() int64 {
|
||||||
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
return time.Now().UnixNano()
|
||||||
result := make([]byte, length)
|
}
|
||||||
for i := range result {
|
|
||||||
result[i] = chars[time.Now().UnixNano()%int64(len(chars))]
|
// secureRandomString 使用 crypto/rand 生成安全的随机十六进制字符串
|
||||||
time.Sleep(time.Nanosecond)
|
func secureRandomString(bytesLen int) string {
|
||||||
}
|
b := make([]byte, bytesLen)
|
||||||
return string(result)
|
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
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"note-manager/model"
|
"note-manager/model"
|
||||||
@@ -48,22 +53,8 @@ func fail(c *gin.Context, status int, msg string) {
|
|||||||
c.JSON(status, Response{Code: -1, Message: msg})
|
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 创建笔记
|
// CreateNote 创建笔记
|
||||||
func (h *NoteHandler) CreateNote(c *gin.Context) {
|
func (h *NoteHandler) CreateNote(c *gin.Context) {
|
||||||
if !requireAuth(c) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req model.NoteCreateRequest
|
var req model.NoteCreateRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
fail(c, http.StatusBadRequest, "请求参数错误: "+err.Error())
|
fail(c, http.StatusBadRequest, "请求参数错误: "+err.Error())
|
||||||
@@ -79,7 +70,9 @@ func (h *NoteHandler) CreateNote(c *gin.Context) {
|
|||||||
success(c, note)
|
success(c, note)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetNote 获取笔记详情
|
// GetNote 获取笔记详情(公开只读接口)
|
||||||
|
// 安全策略:仅允许返回「公开且无密码」的笔记。有密码或未公开的笔记一律返回需授权提示,
|
||||||
|
// 防止通过该接口绕过密码保护读取内容。
|
||||||
func (h *NoteHandler) GetNote(c *gin.Context) {
|
func (h *NoteHandler) GetNote(c *gin.Context) {
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -93,6 +86,29 @@ func (h *NoteHandler) GetNote(c *gin.Context) {
|
|||||||
return
|
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)
|
success(c, note)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +128,7 @@ func (h *NoteHandler) AccessNote(c *gin.Context) {
|
|||||||
req.Password = c.Query("password")
|
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 != nil {
|
||||||
if err.Error() == "密码错误" {
|
if err.Error() == "密码错误" {
|
||||||
fail(c, http.StatusUnauthorized, err.Error())
|
fail(c, http.StatusUnauthorized, err.Error())
|
||||||
@@ -122,15 +138,16 @@ func (h *NoteHandler) AccessNote(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 若旧 SHA-256 哈希命中,自动升级为 bcrypt
|
||||||
|
if upgrade {
|
||||||
|
_ = h.svc.UpgradePasswordHash(note.ID, req.Password)
|
||||||
|
}
|
||||||
|
|
||||||
success(c, note)
|
success(c, note)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateNote 更新笔记
|
// UpdateNote 更新笔记
|
||||||
func (h *NoteHandler) UpdateNote(c *gin.Context) {
|
func (h *NoteHandler) UpdateNote(c *gin.Context) {
|
||||||
if !requireAuth(c) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fail(c, http.StatusBadRequest, "无效的笔记 ID")
|
fail(c, http.StatusBadRequest, "无效的笔记 ID")
|
||||||
@@ -152,12 +169,8 @@ func (h *NoteHandler) UpdateNote(c *gin.Context) {
|
|||||||
success(c, note)
|
success(c, note)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteNote 删除笔记
|
// DeleteNote 删除笔记(软删除,进入回收站)
|
||||||
func (h *NoteHandler) DeleteNote(c *gin.Context) {
|
func (h *NoteHandler) DeleteNote(c *gin.Context) {
|
||||||
if !requireAuth(c) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fail(c, http.StatusBadRequest, "无效的笔记 ID")
|
fail(c, http.StatusBadRequest, "无效的笔记 ID")
|
||||||
@@ -287,10 +300,168 @@ func parseIntDefault(s string, defaultVal int) int {
|
|||||||
return v
|
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 文件
|
// ExportNote 导出笔记为 Markdown 文件
|
||||||
func (h *NoteHandler) ExportNote(c *gin.Context) {
|
func (h *NoteHandler) ExportNote(c *gin.Context) {
|
||||||
idStr := c.Param("id")
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fail(c, http.StatusBadRequest, "无效的笔记 ID")
|
fail(c, http.StatusBadRequest, "无效的笔记 ID")
|
||||||
return
|
return
|
||||||
@@ -302,55 +473,103 @@ func (h *NoteHandler) ExportNote(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果是目录,导出目录下所有笔记
|
// 构造 Markdown(带 YAML front matter)
|
||||||
if note.IsFolder {
|
content := buildMarkdown(note)
|
||||||
fail(c, http.StatusBadRequest, "不支持导出目录,请选择具体笔记")
|
|
||||||
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置下载头
|
// 构建节点 map 与根节点列表
|
||||||
filename := note.Title + ".md"
|
type node struct {
|
||||||
c.Header("Content-Disposition", "attachment; filename*=UTF-8''"+urlEncode(filename))
|
item model.NoteListItem
|
||||||
c.Header("Content-Type", "text/markdown; charset=utf-8")
|
children []*node
|
||||||
|
|
||||||
// 添加 front matter
|
|
||||||
frontMatter := "---\n"
|
|
||||||
frontMatter += "title: " + note.Title + "\n"
|
|
||||||
if note.Category != "" {
|
|
||||||
frontMatter += "category: " + note.Category + "\n"
|
|
||||||
}
|
}
|
||||||
if note.Tags != "" {
|
nodeMap := make(map[uint]*node)
|
||||||
frontMatter += "tags: " + note.Tags + "\n"
|
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) {
|
func (h *NoteHandler) ImportNotes(c *gin.Context) {
|
||||||
file, err := c.FormFile("file")
|
file, err := c.FormFile("file")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fail(c, http.StatusBadRequest, "请选择文件")
|
fail(c, http.StatusBadRequest, "请选择要导入的文件")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证文件类型
|
if !strings.HasSuffix(strings.ToLower(file.Filename), ".md") {
|
||||||
if file.Header.Get("Content-Type") != "text/markdown" &&
|
fail(c, http.StatusBadRequest, "仅支持导入 .md 文件")
|
||||||
!strings.HasSuffix(file.Filename, ".md") {
|
|
||||||
fail(c, http.StatusBadRequest, "仅支持 .md 文件")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 读取文件内容
|
// 读取文件内容
|
||||||
f, err := file.Open()
|
src, err := file.Open()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fail(c, http.StatusInternalServerError, "读取文件失败")
|
fail(c, http.StatusInternalServerError, "读取文件失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer f.Close()
|
defer src.Close()
|
||||||
|
contentBytes, err := io.ReadAll(src)
|
||||||
contentBytes, err := io.ReadAll(f)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fail(c, http.StatusInternalServerError, "读取文件失败")
|
fail(c, http.StatusInternalServerError, "读取文件失败")
|
||||||
return
|
return
|
||||||
@@ -406,3 +625,39 @@ func parseFrontMatter(content string) (title string, body string) {
|
|||||||
func urlEncode(s string) string {
|
func urlEncode(s string) string {
|
||||||
return url.QueryEscape(s)
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ func main() {
|
|||||||
log.Printf("前台展示: http://localhost%s/", addr)
|
log.Printf("前台展示: http://localhost%s/", addr)
|
||||||
log.Printf("后台管理: http://localhost%s/admin/", addr)
|
log.Printf("后台管理: http://localhost%s/admin/", addr)
|
||||||
log.Printf("图片上传目录: %s", cfg.UploadDir)
|
log.Printf("图片上传目录: %s", cfg.UploadDir)
|
||||||
log.Printf("默认密码: %s", cfg.AdminPass)
|
log.Printf("后台登录密码请通过 ADMIN_PASS 环境变量配置(生产环境务必修改默认密码)")
|
||||||
|
|
||||||
if err := engine.Run(addr); err != nil {
|
if err := engine.Run(addr); err != nil {
|
||||||
log.Fatalf("启动服务失败: %v", err)
|
log.Fatalf("启动服务失败: %v", err)
|
||||||
|
|||||||
+37
-8
@@ -1,20 +1,49 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import "github.com/gin-gonic/gin"
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
// CORS 跨域中间件
|
// CORS 跨域中间件
|
||||||
|
// 这是同源应用(前端、后台、API 都在同一服务),因此默认不对外部跨域放行。
|
||||||
|
// 仅允许本站自身的 Origin 访问,避免任意外部站点的跨域请求读取或携带凭证。
|
||||||
func CORS() gin.HandlerFunc {
|
func CORS() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
c.Header("Access-Control-Allow-Origin", "*")
|
origin := c.GetHeader("Origin")
|
||||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
host := c.Request.Host
|
||||||
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization")
|
|
||||||
c.Header("Access-Control-Max-Age", "86400")
|
|
||||||
|
|
||||||
if c.Request.Method == "OPTIONS" {
|
// 同源请求或没有 Origin 的请求(curl 等)直接放行
|
||||||
c.AbortWithStatus(204)
|
if origin == "" || strings.Contains(origin, "://"+host) {
|
||||||
|
// 仍允许同源反射,方便调试
|
||||||
|
if origin != "" {
|
||||||
|
c.Header("Access-Control-Allow-Origin", origin)
|
||||||
|
c.Header("Vary", "Origin")
|
||||||
|
}
|
||||||
|
if c.Request.Method == http.MethodOptions {
|
||||||
|
c.AbortWithStatus(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Next()
|
// 非同源且非本站:拒绝携带凭证的跨域请求
|
||||||
|
if c.Request.Method == http.MethodOptions {
|
||||||
|
// 预检请求直接拒绝
|
||||||
|
c.AbortWithStatus(http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 允许无凭证的只读公开请求,但不返回 Allow-Origin,浏览器会拦截跨域读取
|
||||||
|
if c.Request.Method == http.MethodGet {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写操作(POST/PUT/DELETE)来自外部站点的跨域请求:拦截
|
||||||
|
c.AbortWithStatus(http.StatusForbidden)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- 服务端会话存储(内存) ----
|
||||||
|
// 用随机 token 代替之前的固定字符串 cookie,登出/过期即失效。
|
||||||
|
|
||||||
|
var (
|
||||||
|
sessions = make(map[string]time.Time) // token -> 过期时间
|
||||||
|
sessionsMu sync.RWMutex
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
CookieName = "admin_token"
|
||||||
|
SessionTTL = 7 * 24 * time.Hour // 会话有效期 7 天
|
||||||
|
CookieMaxAge = 7 * 24 * 3600 // cookie 有效期(秒)
|
||||||
|
sessionCleanT = 10 // 清理过期会话的间隔(分钟)
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewSessionToken 生成一个新的会话 token 并注册
|
||||||
|
func NewSessionToken() (string, time.Time) {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
// 兜底:用时间戳+纳秒(理论上不会发生)
|
||||||
|
b = []byte(time.Now().Format("20060102150405.000000000"))
|
||||||
|
}
|
||||||
|
token := hex.EncodeToString(b)
|
||||||
|
exp := time.Now().Add(SessionTTL)
|
||||||
|
sessionsMu.Lock()
|
||||||
|
sessions[token] = exp
|
||||||
|
sessionsMu.Unlock()
|
||||||
|
go cleanExpiredSessions()
|
||||||
|
return token, exp
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeSession 登出时删除会话
|
||||||
|
func RevokeSession(token string) {
|
||||||
|
sessionsMu.Lock()
|
||||||
|
delete(sessions, token)
|
||||||
|
sessionsMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsValidSession 校验 token 是否有效且未过期
|
||||||
|
func IsValidSession(token string) bool {
|
||||||
|
if token == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
sessionsMu.RLock()
|
||||||
|
exp, ok := sessions[token]
|
||||||
|
sessionsMu.RUnlock()
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if time.Now().After(exp) {
|
||||||
|
RevokeSession(token)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanExpiredSessions 定期清理过期会话,防止内存泄漏
|
||||||
|
func cleanExpiredSessions() {
|
||||||
|
sessionsMu.Lock()
|
||||||
|
defer sessionsMu.Unlock()
|
||||||
|
for token, exp := range sessions {
|
||||||
|
if time.Now().After(exp) {
|
||||||
|
delete(sessions, token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthRequired 管理接口认证中间件
|
||||||
|
func AuthRequired() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
token, err := c.Cookie(CookieName)
|
||||||
|
if err != nil || !IsValidSession(token) {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "请先登录"})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAuthCookie 设置认证 cookie(SameSite=Lax 防 CSRF;https 下应启用 Secure)
|
||||||
|
func SetAuthCookie(c *gin.Context, token string, secure bool) {
|
||||||
|
c.SetSameSite(http.SameSiteLaxMode)
|
||||||
|
c.SetCookie(CookieName, token, CookieMaxAge, "/", "", secure, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearAuthCookie 清除认证 cookie
|
||||||
|
func ClearAuthCookie(c *gin.Context) {
|
||||||
|
c.SetSameSite(http.SameSiteLaxMode)
|
||||||
|
c.SetCookie(CookieName, "", -1, "/", "", false, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAuthToken 从请求读取 token
|
||||||
|
func GetAuthToken(c *gin.Context) string {
|
||||||
|
token, _ := c.Cookie(CookieName)
|
||||||
|
return token
|
||||||
|
}
|
||||||
+98
-49
@@ -4,63 +4,96 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HashPassword 生成密码哈希
|
// bcryptCost 密码哈希成本因子
|
||||||
|
const bcryptCost = 10
|
||||||
|
|
||||||
|
// HashPassword 生成密码哈希(优先使用 bcrypt,向后兼容旧的 SHA-256)
|
||||||
|
// 返回值格式:new(bcrypt)哈希以 $2 开头;旧的 SHA-256 以 64 位十六进制开头。
|
||||||
func HashPassword(password string) string {
|
func HashPassword(password string) string {
|
||||||
if password == "" {
|
if password == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
hash := sha256.Sum256([]byte(password))
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
|
||||||
return hex.EncodeToString(hash[:])
|
if err != nil {
|
||||||
|
// 极端情况下 fallback 到 sha256
|
||||||
|
s := sha256.Sum256([]byte(password))
|
||||||
|
return hex.EncodeToString(s[:])
|
||||||
|
}
|
||||||
|
return string(hash)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckPassword 验证密码
|
// IsBcryptHash 判断哈希是否为 bcrypt 格式
|
||||||
func CheckPassword(password, hash string) bool {
|
func IsBcryptHash(hash string) bool {
|
||||||
// 如果笔记没有设置密码(hash 为空),则不需要验证
|
return len(hash) >= 4 && hash[:4] == "$2a$" || (len(hash) >= 4 && hash[:4] == "$2b$") || (len(hash) >= 4 && hash[:4] == "$2y$")
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckPassword 验证密码(兼容 bcrypt 与旧 SHA-256 哈希)
|
||||||
|
// 第二个返回值表示是否应当把存储的哈希升级为 bcrypt(旧 sha256 命中时返回 true)
|
||||||
|
func CheckPassword(password, hash string) (bool, bool) {
|
||||||
if hash == "" {
|
if hash == "" {
|
||||||
return true
|
return true, false
|
||||||
}
|
}
|
||||||
// 如果用户没有输入密码,验证失败
|
|
||||||
if password == "" {
|
if password == "" {
|
||||||
return false
|
return false, false
|
||||||
}
|
}
|
||||||
return HashPassword(password) == hash
|
if IsBcryptHash(hash) {
|
||||||
|
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||||
|
return err == nil, false
|
||||||
|
}
|
||||||
|
// 旧的 SHA-256 哈希
|
||||||
|
s := sha256.Sum256([]byte(password))
|
||||||
|
if hex.EncodeToString(s[:]) == hash {
|
||||||
|
// 命中旧哈希,建议升级为 bcrypt
|
||||||
|
return true, true
|
||||||
|
}
|
||||||
|
return false, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note 笔记模型(也用于目录)
|
// Note 笔记模型(也用于目录)
|
||||||
type Note struct {
|
type Note struct {
|
||||||
ID uint `json:"id" gorm:"primaryKey"`
|
ID uint `json:"id" gorm:"primaryKey"`
|
||||||
Title string `json:"title" gorm:"size:255;not null" binding:"required"`
|
Title string `json:"title" gorm:"size:255;not null"`
|
||||||
Content string `json:"content" gorm:"type:text"`
|
Content string `json:"content" gorm:"type:text"`
|
||||||
Category string `json:"category" gorm:"size:100;index"`
|
Category string `json:"category" gorm:"size:100;index"`
|
||||||
Tags string `json:"tags" gorm:"type:text"` // JSON 数组格式存储
|
Tags string `json:"tags" gorm:"type:text"` // JSON 数组格式存储
|
||||||
Password string `json:"-" gorm:"size:255"` // 访问密码(哈希存储)
|
Password string `json:"-" gorm:"size:255"` // 访问密码(哈希存储)
|
||||||
IsPinned bool `json:"is_pinned" gorm:"default:false"`
|
IsPinned bool `json:"is_pinned" gorm:"default:false"`
|
||||||
IsFavorite bool `json:"is_favorite" gorm:"default:false"`
|
IsFavorite bool `json:"is_favorite" gorm:"default:false"`
|
||||||
IsPublic bool `json:"is_public"` // 是否公开
|
IsPublic bool `json:"is_public"` // 是否公开
|
||||||
ParentID uint `json:"parent_id" gorm:"default:0;index"` // 父级目录 ID,0 表示根目录
|
ParentID uint `json:"parent_id" gorm:"default:0;index"` // 父级目录 ID,0 表示根目录
|
||||||
IsFolder bool `json:"is_folder" gorm:"default:false"` // 是否为文件夹
|
IsFolder bool `json:"is_folder" gorm:"default:false"` // 是否为文件夹
|
||||||
SortOrder int `json:"sort_order" gorm:"default:0"` // 排序顺序
|
SortOrder int `json:"sort_order" gorm:"default:0"` // 排序顺序
|
||||||
CreatedAt time.Time `json:"created_at"`
|
ShareToken string `json:"-" gorm:"size:64;index"` // 分享令牌,空 = 未分享
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
ShareExpireAt *time.Time `json:"share_expire_at,omitempty"` // 分享过期时间,nil = 永久
|
||||||
|
VisitCount int `json:"visit_count" gorm:"default:0"` // 浏览次数
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` // 软删除
|
||||||
}
|
}
|
||||||
|
|
||||||
// NoteListItem 笔记列表响应(不含内容和密码)
|
// NoteListItem 笔记列表响应(不含内容和密码)
|
||||||
type NoteListItem struct {
|
type NoteListItem struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Category string `json:"category"`
|
Category string `json:"category"`
|
||||||
Tags string `json:"tags"`
|
Tags string `json:"tags"`
|
||||||
HasPassword bool `json:"has_password"` // 是否有密码保护
|
HasPassword bool `json:"has_password"` // 是否有密码保护
|
||||||
IsPinned bool `json:"is_pinned"`
|
IsPinned bool `json:"is_pinned"`
|
||||||
IsFavorite bool `json:"is_favorite"`
|
IsFavorite bool `json:"is_favorite"`
|
||||||
IsPublic bool `json:"is_public"`
|
IsPublic bool `json:"is_public"`
|
||||||
ParentID uint `json:"parent_id"`
|
ParentID uint `json:"parent_id"`
|
||||||
IsFolder bool `json:"is_folder"`
|
IsFolder bool `json:"is_folder"`
|
||||||
SortOrder int `json:"sort_order"`
|
SortOrder int `json:"sort_order"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
ShareToken string `json:"share_token,omitempty"` // 分享令牌(后台完整列表需要)
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
ShareExpireAt *time.Time `json:"share_expire_at,omitempty"`
|
||||||
|
VisitCount int `json:"visit_count"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NoteCreateRequest 创建笔记请求
|
// NoteCreateRequest 创建笔记请求
|
||||||
@@ -80,21 +113,37 @@ type NoteCreateRequest struct {
|
|||||||
|
|
||||||
// NoteUpdateRequest 更新笔记请求
|
// NoteUpdateRequest 更新笔记请求
|
||||||
type NoteUpdateRequest struct {
|
type NoteUpdateRequest struct {
|
||||||
Title *string `json:"title"`
|
Title *string `json:"title"`
|
||||||
Content *string `json:"content"`
|
Content *string `json:"content"`
|
||||||
Category *string `json:"category"`
|
Category *string `json:"category"`
|
||||||
Tags *string `json:"tags"`
|
Tags *string `json:"tags"`
|
||||||
Password *string `json:"password"`
|
Password *string `json:"password"`
|
||||||
RemovePassword *bool `json:"remove_password"` // 是否移除密码
|
RemovePassword *bool `json:"remove_password"` // 是否移除密码
|
||||||
IsPinned *bool `json:"is_pinned"`
|
IsPinned *bool `json:"is_pinned"`
|
||||||
IsFavorite *bool `json:"is_favorite"`
|
IsFavorite *bool `json:"is_favorite"`
|
||||||
IsPublic *bool `json:"is_public"`
|
IsPublic *bool `json:"is_public"`
|
||||||
ParentID *uint `json:"parent_id"`
|
ParentID *uint `json:"parent_id"`
|
||||||
IsFolder *bool `json:"is_folder"`
|
IsFolder *bool `json:"is_folder"`
|
||||||
SortOrder *int `json:"sort_order"`
|
SortOrder *int `json:"sort_order"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NoteAccessRequest 笔记访问请求(验证密码)
|
// NoteAccessRequest 笔记访问请求(验证密码)
|
||||||
type NoteAccessRequest struct {
|
type NoteAccessRequest struct {
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NoteVersion 笔记版本历史
|
||||||
|
type NoteVersion struct {
|
||||||
|
ID uint `json:"id" gorm:"primaryKey"`
|
||||||
|
NoteID uint `json:"note_id" gorm:"index"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Content string `json:"content" gorm:"type:text"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
Tags string `json:"tags"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName 指定版本表名
|
||||||
|
func (NoteVersion) TableName() string {
|
||||||
|
return "note_versions"
|
||||||
|
}
|
||||||
|
|||||||
+204
-18
@@ -1,6 +1,7 @@
|
|||||||
package repository
|
package repository
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -28,8 +29,11 @@ func NewNoteRepository(dbPath string) (*NoteRepository, error) {
|
|||||||
return nil, fmt.Errorf("连接数据库失败: %w", err)
|
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)
|
return nil, fmt.Errorf("数据库迁移失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,10 +42,11 @@ func NewNoteRepository(dbPath string) (*NoteRepository, error) {
|
|||||||
|
|
||||||
// Create 创建笔记
|
// Create 创建笔记
|
||||||
func (r *NoteRepository) Create(note *model.Note) error {
|
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) {
|
func (r *NoteRepository) GetByID(id uint) (*model.Note, error) {
|
||||||
var note model.Note
|
var note model.Note
|
||||||
err := r.db.First(¬e, id).Error
|
err := r.db.First(¬e, id).Error
|
||||||
@@ -51,12 +56,27 @@ func (r *NoteRepository) GetByID(id uint) (*model.Note, error) {
|
|||||||
return ¬e, nil
|
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 更新笔记
|
// Update 更新笔记
|
||||||
func (r *NoteRepository) Update(note *model.Note) error {
|
func (r *NoteRepository) Update(note *model.Note) error {
|
||||||
return r.db.Save(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 {
|
func (r *NoteRepository) Delete(id uint) error {
|
||||||
return r.db.Delete(&model.Note{}, id).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
|
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").
|
Order("is_folder DESC, sort_order ASC, updated_at DESC").
|
||||||
Offset(offset).
|
Offset(offset).
|
||||||
Limit(q.PageSize).
|
Limit(q.PageSize).
|
||||||
@@ -109,17 +129,17 @@ func (r *NoteRepository) List(q ListQuery) ([]model.NoteListItem, int64, error)
|
|||||||
return items, total, err
|
return items, total, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAllTree 获取所有笔记的树形结构
|
// GetAllTree 获取所有笔记的树形结构(含分享信息)
|
||||||
func (r *NoteRepository) GetAllTree() ([]model.NoteListItem, error) {
|
func (r *NoteRepository) GetAllTree() ([]model.NoteListItem, error) {
|
||||||
var items []model.NoteListItem
|
var items []model.NoteListItem
|
||||||
err := r.db.Model(&model.Note{}).
|
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").
|
Order("is_folder DESC, sort_order ASC, title ASC").
|
||||||
Find(&items).Error
|
Find(&items).Error
|
||||||
return items, err
|
return items, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPublicTree 获取公开可见的树形结构(显示所有目录和笔记,访问时再验证密码)
|
// GetPublicTree 获取公开可见的树形结构(前台用,不含分享令牌)
|
||||||
func (r *NoteRepository) GetPublicTree() ([]model.NoteListItem, error) {
|
func (r *NoteRepository) GetPublicTree() ([]model.NoteListItem, error) {
|
||||||
var items []model.NoteListItem
|
var items []model.NoteListItem
|
||||||
err := r.db.Model(&model.Note{}).
|
err := r.db.Model(&model.Note{}).
|
||||||
@@ -129,7 +149,7 @@ func (r *NoteRepository) GetPublicTree() ([]model.NoteListItem, error) {
|
|||||||
return items, err
|
return items, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetByParentID 获取指定父目录下的所有项目
|
// GetByParentID 获取指定父目录下的所有项目(排除已删除)
|
||||||
func (r *NoteRepository) GetByParentID(parentID uint) ([]model.NoteListItem, error) {
|
func (r *NoteRepository) GetByParentID(parentID uint) ([]model.NoteListItem, error) {
|
||||||
var items []model.NoteListItem
|
var items []model.NoteListItem
|
||||||
err := r.db.Model(&model.Note{}).
|
err := r.db.Model(&model.Note{}).
|
||||||
@@ -140,21 +160,121 @@ func (r *NoteRepository) GetByParentID(parentID uint) ([]model.NoteListItem, err
|
|||||||
return items, err
|
return items, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetChildrenCount 获取子项数量
|
// GetChildrenCount 获取子项数量(排除已删除)
|
||||||
func (r *NoteRepository) GetChildrenCount(parentID uint) (int64, error) {
|
func (r *NoteRepository) GetChildrenCount(parentID uint) (int64, error) {
|
||||||
var count int64
|
var count int64
|
||||||
err := r.db.Model(&model.Note{}).Where("parent_id = ?", parentID).Count(&count).Error
|
err := r.db.Model(&model.Note{}).Where("parent_id = ?", parentID).Count(&count).Error
|
||||||
return count, err
|
return count, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteWithChildren 删除目录及其下所有内容
|
// DeleteWithChildren 软删除目录及其下所有内容
|
||||||
func (r *NoteRepository) DeleteWithChildren(id uint) error {
|
func (r *NoteRepository) DeleteWithChildren(id uint) error {
|
||||||
// 先删除所有子项
|
// 递归收集所有后代 id
|
||||||
if err := r.db.Where("parent_id = ?", id).Delete(&model.Note{}).Error; err != nil {
|
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 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 搜索笔记(按标题和内容)
|
// Search 搜索笔记(按标题和内容)
|
||||||
@@ -170,7 +290,7 @@ func (r *NoteRepository) Search(keyword string, page, pageSize int) ([]model.Not
|
|||||||
}
|
}
|
||||||
|
|
||||||
offset := (page - 1) * pageSize
|
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").
|
Order("is_pinned DESC, updated_at DESC").
|
||||||
Offset(offset).
|
Offset(offset).
|
||||||
Limit(pageSize).
|
Limit(pageSize).
|
||||||
@@ -179,7 +299,7 @@ func (r *NoteRepository) Search(keyword string, page, pageSize int) ([]model.Not
|
|||||||
return items, total, err
|
return items, total, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCategories 获取所有分类
|
// GetCategories 获取所有分类(排除已删除和目录)
|
||||||
func (r *NoteRepository) GetCategories() ([]string, error) {
|
func (r *NoteRepository) GetCategories() ([]string, error) {
|
||||||
var categories []string
|
var categories []string
|
||||||
err := r.db.Model(&model.Note{}).
|
err := r.db.Model(&model.Note{}).
|
||||||
@@ -189,7 +309,7 @@ func (r *NoteRepository) GetCategories() ([]string, error) {
|
|||||||
return categories, err
|
return categories, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTags 获取所有标签
|
// GetTags 获取所有标签(排除已删除和目录)
|
||||||
func (r *NoteRepository) GetTags() ([]string, error) {
|
func (r *NoteRepository) GetTags() ([]string, error) {
|
||||||
var tagsJSON []string
|
var tagsJSON []string
|
||||||
err := r.db.Model(&model.Note{}).
|
err := r.db.Model(&model.Note{}).
|
||||||
@@ -210,3 +330,69 @@ func (r *NoteRepository) GetTags() ([]string, error) {
|
|||||||
}
|
}
|
||||||
return result, nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
+39
-23
@@ -15,47 +15,60 @@ func Setup(r *gin.Engine, noteHandler *handler.NoteHandler, adminHandler *handle
|
|||||||
// 静态文件服务(图片)
|
// 静态文件服务(图片)
|
||||||
r.Static("/uploads", cfg.UploadDir)
|
r.Static("/uploads", cfg.UploadDir)
|
||||||
|
|
||||||
// API 路由组
|
// ─────────── 公开 API ───────────
|
||||||
api := r.Group("/api")
|
api := r.Group("/api")
|
||||||
{
|
{
|
||||||
notes := api.Group("/notes")
|
notes := api.Group("/notes")
|
||||||
{
|
{
|
||||||
// 公开只读接口
|
|
||||||
notes.GET("", noteHandler.ListNotes)
|
notes.GET("", noteHandler.ListNotes)
|
||||||
notes.GET("/search", noteHandler.SearchNotes)
|
notes.GET("/search", noteHandler.SearchNotes)
|
||||||
notes.GET("/:id", noteHandler.GetNote)
|
notes.GET("/:id", noteHandler.GetNote) // 公开安全版:仅公开且无密码的笔记
|
||||||
notes.POST("/:id/access", noteHandler.AccessNote) // 密码验证访问
|
notes.POST("/:id/access", noteHandler.AccessNote) // 密码验证访问
|
||||||
|
|
||||||
// 需要认证的管理接口
|
|
||||||
notes.POST("", noteHandler.CreateNote)
|
|
||||||
notes.PUT("/:id", noteHandler.UpdateNote)
|
|
||||||
notes.DELETE("/:id", noteHandler.DeleteNote)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
api.GET("/categories", noteHandler.GetCategories)
|
api.GET("/categories", noteHandler.GetCategories)
|
||||||
api.GET("/tags", noteHandler.GetTags)
|
api.GET("/tags", noteHandler.GetTags)
|
||||||
api.GET("/tree", noteHandler.GetPublicTree) // 前台公开树
|
api.GET("/tree", noteHandler.GetPublicTree) // 前台公开树
|
||||||
|
|
||||||
|
// 分享 JSON 接口(公开)
|
||||||
|
api.GET("/share/:token", noteHandler.GetSharedNote)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 管理后台专用 API(需要认证)
|
// ─────────── 管理后台 API(需认证)───────────
|
||||||
adminApi := r.Group("/admin/api")
|
adminApi := r.Group("/admin/api")
|
||||||
adminApi.Use(func(c *gin.Context) {
|
adminApi.Use(middleware.AuthRequired())
|
||||||
token, err := c.Cookie("admin_token")
|
|
||||||
if err != nil || token != "authenticated" {
|
|
||||||
c.JSON(401, gin.H{"code": 401, "message": "请先登录"})
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Next()
|
|
||||||
})
|
|
||||||
{
|
{
|
||||||
adminApi.GET("/tree", noteHandler.GetTree) // 后台完整树
|
// 笔记写操作
|
||||||
adminApi.POST("/upload", imageHandler.Upload) // 图片上传
|
adminApi.POST("/notes", noteHandler.CreateNote)
|
||||||
adminApi.GET("/export/:id", noteHandler.ExportNote) // 导出笔记
|
adminApi.GET("/notes/:id", noteHandler.GetAdminNote)
|
||||||
adminApi.POST("/import", noteHandler.ImportNotes) // 导入笔记
|
adminApi.PUT("/notes/:id", noteHandler.UpdateNote)
|
||||||
|
adminApi.DELETE("/notes/:id", noteHandler.DeleteNote)
|
||||||
|
|
||||||
|
// 回收站
|
||||||
|
adminApi.GET("/trash", noteHandler.ListTrash)
|
||||||
|
adminApi.POST("/restore/:id", noteHandler.RestoreNote)
|
||||||
|
adminApi.POST("/purge/:id", noteHandler.PurgeNote)
|
||||||
|
adminApi.POST("/empty-trash", noteHandler.EmptyTrash)
|
||||||
|
|
||||||
|
// 版本历史
|
||||||
|
adminApi.GET("/notes/:id/versions", noteHandler.ListVersions)
|
||||||
|
adminApi.POST("/notes/:id/restore-version", noteHandler.RestoreVersion)
|
||||||
|
|
||||||
|
// 分享
|
||||||
|
adminApi.POST("/notes/:id/share", noteHandler.CreateShare)
|
||||||
|
adminApi.POST("/notes/:id/revoke-share", noteHandler.RevokeShare)
|
||||||
|
|
||||||
|
// 导入导出
|
||||||
|
adminApi.GET("/export/:id", noteHandler.ExportNote)
|
||||||
|
adminApi.GET("/export-all", noteHandler.ExportAll)
|
||||||
|
adminApi.POST("/import", noteHandler.ImportNotes)
|
||||||
|
|
||||||
|
// 完整树 + 图片上传
|
||||||
|
adminApi.GET("/tree", noteHandler.GetTree)
|
||||||
|
adminApi.POST("/upload", imageHandler.Upload)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 后台管理路由
|
// ─────────── 后台管理路由 ───────────
|
||||||
admin := r.Group("/admin")
|
admin := r.Group("/admin")
|
||||||
{
|
{
|
||||||
admin.GET("/login", adminHandler.LoginPage)
|
admin.GET("/login", adminHandler.LoginPage)
|
||||||
@@ -65,6 +78,9 @@ func Setup(r *gin.Engine, noteHandler *handler.NoteHandler, adminHandler *handle
|
|||||||
admin.GET("/", adminHandler.IndexPage)
|
admin.GET("/", adminHandler.IndexPage)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────── 分享阅读页 ───────────
|
||||||
|
r.GET("/share/:token", noteHandler.SharePage)
|
||||||
|
|
||||||
// 健康检查
|
// 健康检查
|
||||||
r.GET("/health", func(c *gin.Context) {
|
r.GET("/health", func(c *gin.Context) {
|
||||||
c.JSON(200, gin.H{"status": "ok"})
|
c.JSON(200, gin.H{"status": "ok"})
|
||||||
|
|||||||
+190
-17
@@ -1,10 +1,14 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
"note-manager/model"
|
"note-manager/model"
|
||||||
"note-manager/repository"
|
"note-manager/repository"
|
||||||
)
|
)
|
||||||
@@ -23,13 +27,13 @@ func NewNoteService(repo *repository.NoteRepository, pageSize int) *NoteService
|
|||||||
// CreateNote 创建笔记或目录
|
// CreateNote 创建笔记或目录
|
||||||
func (s *NoteService) CreateNote(req model.NoteCreateRequest) (*model.Note, error) {
|
func (s *NoteService) CreateNote(req model.NoteCreateRequest) (*model.Note, error) {
|
||||||
note := &model.Note{
|
note := &model.Note{
|
||||||
Title: req.Title,
|
Title: req.Title,
|
||||||
Content: req.Content,
|
Content: req.Content,
|
||||||
Category: req.Category,
|
Category: req.Category,
|
||||||
Tags: req.Tags,
|
Tags: req.Tags,
|
||||||
IsFolder: req.IsFolder,
|
IsFolder: req.IsFolder,
|
||||||
SortOrder: req.SortOrder,
|
SortOrder: req.SortOrder,
|
||||||
IsPublic: true, // 默认公开
|
IsPublic: true, // 默认公开
|
||||||
}
|
}
|
||||||
if req.Password != "" {
|
if req.Password != "" {
|
||||||
note.Password = model.HashPassword(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 {
|
if err := s.repo.Create(note); err != nil {
|
||||||
return nil, fmt.Errorf("创建笔记失败: %w", err)
|
return nil, fmt.Errorf("创建笔记失败: %w", err)
|
||||||
}
|
}
|
||||||
|
// 创建时保存第一版历史
|
||||||
|
_, _ = s.repo.SaveVersion(note)
|
||||||
return note, nil
|
return note, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetNote 获取单条笔记
|
// GetNote 获取单条笔记(后台管理使用,任意笔记)
|
||||||
func (s *NoteService) GetNote(id uint) (*model.Note, error) {
|
func (s *NoteService) GetNote(id uint) (*model.Note, error) {
|
||||||
note, err := s.repo.GetByID(id)
|
note, err := s.repo.GetByID(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.New("笔记不存在")
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, errors.New("笔记不存在")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
return note, nil
|
return note, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetNoteContent 获取笔记内容(需要密码验证)
|
// GetNoteContent 获取笔记内容(需密码验证,用于前台展示)
|
||||||
func (s *NoteService) GetNoteContent(id uint, password string) (*model.Note, error) {
|
// 返回 (note, 是否需要升级密码哈希, err)
|
||||||
|
func (s *NoteService) GetNoteContent(id uint, password string) (*model.Note, bool, error) {
|
||||||
note, err := s.repo.GetByID(id)
|
note, err := s.repo.GetByID(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.New("笔记不存在")
|
return nil, false, errors.New("笔记不存在")
|
||||||
}
|
}
|
||||||
// 检查密码
|
// 检查密码
|
||||||
if note.Password != "" {
|
if note.Password != "" {
|
||||||
if !model.CheckPassword(password, note.Password) {
|
ok, upgrade := model.CheckPassword(password, note.Password)
|
||||||
return nil, errors.New("密码错误")
|
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) {
|
func (s *NoteService) UpdateNote(id uint, req model.NoteUpdateRequest) (*model.Note, error) {
|
||||||
note, err := s.repo.GetByID(id)
|
note, err := s.repo.GetByID(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.New("笔记不存在")
|
return nil, errors.New("笔记不存在")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 记录旧状态,判断是否产生实质内容变化
|
||||||
|
oldContent := note.Content
|
||||||
|
oldTitle := note.Title
|
||||||
|
|
||||||
if req.Title != nil {
|
if req.Title != nil {
|
||||||
note.Title = *req.Title
|
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 {
|
if req.RemovePassword != nil && *req.RemovePassword {
|
||||||
note.Password = ""
|
note.Password = ""
|
||||||
} else if req.Password != nil {
|
} else if req.Password != nil && *req.Password != "" {
|
||||||
note.Password = model.HashPassword(*req.Password)
|
note.Password = model.HashPassword(*req.Password)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.repo.Update(note); err != nil {
|
if err := s.repo.Update(note); err != nil {
|
||||||
return nil, fmt.Errorf("更新笔记失败: %w", err)
|
return nil, fmt.Errorf("更新笔记失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 若内容或标题发生变化,保存历史版本
|
||||||
|
if oldContent != note.Content || oldTitle != note.Title {
|
||||||
|
_, _ = s.repo.SaveVersion(note)
|
||||||
|
}
|
||||||
return note, nil
|
return note, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteNote 删除笔记或目录(目录会删除所有子项)
|
// DeleteNote 软删除笔记或目录(目录会软删除所有子项)
|
||||||
func (s *NoteService) DeleteNote(id uint) error {
|
func (s *NoteService) DeleteNote(id uint) error {
|
||||||
note, err := s.repo.GetByID(id)
|
note, err := s.repo.GetByID(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -217,6 +238,158 @@ func (s *NoteService) GetTags() ([]string, error) {
|
|||||||
return s.repo.GetTags()
|
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 {
|
func parseInt(s string, defaultVal int) int {
|
||||||
if s == "" {
|
if s == "" {
|
||||||
return defaultVal
|
return defaultVal
|
||||||
|
|||||||
+247
@@ -0,0 +1,247 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""note-manager 新功能端到端冒烟测试"""
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
import urllib.parse
|
||||||
|
import http.cookiejar
|
||||||
|
import io
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
BASE = "http://localhost:8080"
|
||||||
|
cj = http.cookiejar.CookieJar()
|
||||||
|
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj))
|
||||||
|
|
||||||
|
PASSED = 0
|
||||||
|
FAILED = 0
|
||||||
|
|
||||||
|
def report(name, ok, detail=""):
|
||||||
|
global PASSED, FAILED
|
||||||
|
if ok:
|
||||||
|
PASSED += 1
|
||||||
|
print(f" ✅ {name}")
|
||||||
|
else:
|
||||||
|
FAILED += 1
|
||||||
|
print(f" ❌ {name} {detail}")
|
||||||
|
|
||||||
|
def req(method, path, data=None, raw=False, headers=None):
|
||||||
|
url = BASE + path
|
||||||
|
body = None
|
||||||
|
hdrs = dict(headers or {})
|
||||||
|
if data is not None:
|
||||||
|
if isinstance(data, (dict, list)):
|
||||||
|
body = json.dumps(data).encode()
|
||||||
|
hdrs["Content-Type"] = "application/json"
|
||||||
|
elif isinstance(data, str):
|
||||||
|
body = data.encode()
|
||||||
|
hdrs["Content-Type"] = "application/x-www-form-urlencoded"
|
||||||
|
r = urllib.request.Request(url, data=body, method=method, headers=hdrs)
|
||||||
|
try:
|
||||||
|
resp = opener.open(r, timeout=10)
|
||||||
|
content = resp.read()
|
||||||
|
if raw:
|
||||||
|
return resp.status, content, dict(resp.headers)
|
||||||
|
return resp.status, json.loads(content.decode() if content else "{}")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
content = e.read()
|
||||||
|
if raw:
|
||||||
|
return e.code, content, dict(e.headers)
|
||||||
|
try:
|
||||||
|
return e.code, json.loads(content.decode() if content else "{}")
|
||||||
|
except Exception:
|
||||||
|
return e.code, {}
|
||||||
|
except Exception as ex:
|
||||||
|
return -1, {"error": str(ex)}
|
||||||
|
|
||||||
|
print("═══ 1. 认证安全 ═══")
|
||||||
|
|
||||||
|
# 未登录访问管理 API -> 401
|
||||||
|
st, d = req("GET", "/admin/api/tree")
|
||||||
|
report("未登录访问管理API返回401", st == 401, f"(got {st})")
|
||||||
|
|
||||||
|
st, d = req("POST", "/admin/api/notes")
|
||||||
|
report("未登录创建笔记返回401", st == 401, f"(got {st})")
|
||||||
|
|
||||||
|
st, d = req("POST", "/admin/notes")
|
||||||
|
report("未登录(错误路径)不泄露", st in (401, 404), f"(got {st})")
|
||||||
|
|
||||||
|
# 错误密码登录
|
||||||
|
st, d = req("POST", "/admin/login", "password=wrongpass")
|
||||||
|
report("错误密码登录返回401", st == 401, f"(got {st})")
|
||||||
|
|
||||||
|
# 正确登录
|
||||||
|
st, d = req("POST", "/admin/login", "password=admin123")
|
||||||
|
report("正确密码登录成功", st == 200 and d.get("code") == 0, f"(got {st} {d})")
|
||||||
|
|
||||||
|
# 登录后可访问
|
||||||
|
st, d = req("GET", "/admin/api/tree")
|
||||||
|
report("登录后访问管理API成功", st == 200 and d.get("code") == 0, f"(got {st})")
|
||||||
|
|
||||||
|
# 旧固定 cookie 不再有效
|
||||||
|
class TmpOpener:
|
||||||
|
pass
|
||||||
|
# 手动构造请求带旧的 fixed cookie
|
||||||
|
import urllib.request as u
|
||||||
|
reqf = urllib.request.Request(BASE + "/admin/api/tree", headers={"Cookie": "admin_token=authenticated"})
|
||||||
|
try:
|
||||||
|
r = u.urlopen(reqf, timeout=10)
|
||||||
|
st_old = r.status
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
st_old = e.code
|
||||||
|
report("固定字符串cookie已失效(返回401)", st_old == 401, f"(got {st_old})")
|
||||||
|
|
||||||
|
print("═══ 2. 笔记 CRUD + 版本历史 ═══")
|
||||||
|
|
||||||
|
st, d = req("POST", "/admin/api/notes", {
|
||||||
|
"title": "测试笔记A", "content": "第一版内容", "category": "测试", "tags": '["go","测试"]', "is_public": True
|
||||||
|
})
|
||||||
|
note_id = d.get("data", {}).get("id")
|
||||||
|
report("创建笔记成功", st == 200 and note_id, f"(got {st} {d})")
|
||||||
|
|
||||||
|
st, d = req("PUT", f"/admin/api/notes/{note_id}", {"content": "第二版内容更新了"})
|
||||||
|
report("更新笔记成功", st == 200 and d.get("code") == 0, f"(got {st})")
|
||||||
|
|
||||||
|
st, d = req("GET", f"/admin/api/notes/{note_id}/versions")
|
||||||
|
vers = d.get("data", [])
|
||||||
|
report("版本历史记录>=2", st == 200 and len(vers) >= 2, f"(got {len(vers)})")
|
||||||
|
|
||||||
|
# 回滚到第一版
|
||||||
|
old_ver = vers[-1]
|
||||||
|
st, d = req("POST", f"/admin/api/notes/{note_id}/restore-version", "version_id=%d" % old_ver["id"])
|
||||||
|
report("恢复历史版本成功", st == 200 and d.get("code") == 0, f"(got {st})")
|
||||||
|
|
||||||
|
st, d = req("GET", f"/admin/api/notes/{note_id}")
|
||||||
|
report("恢复后内容为第一版", d.get("data", {}).get("content") == "第一版内容", f"(got {d.get('data',{}).get('content')})")
|
||||||
|
|
||||||
|
print("═══ 3. 安全:公开接口不泄露带密码笔记 ═══")
|
||||||
|
|
||||||
|
# 创建一个带密码的公开笔记
|
||||||
|
st, d = req("POST", "/admin/api/notes", {
|
||||||
|
"title": "受保护笔记", "content": "秘密内容", "is_public": True, "password": "secret123"
|
||||||
|
})
|
||||||
|
protected_id = d.get("data", {}).get("id")
|
||||||
|
report("创建带密码笔记成功", st == 200 and protected_id, f"(got {st})")
|
||||||
|
|
||||||
|
# 通过公开接口 GET /api/notes/:id 访问 -> 应被拒
|
||||||
|
st, d = req("GET", f"/api/notes/{protected_id}")
|
||||||
|
report("公开接口拒绝带密码笔记", st in (401, 403), f"(got {st})")
|
||||||
|
|
||||||
|
# 密码验证接口应能正确返回
|
||||||
|
st, d = req("POST", f"/api/notes/{protected_id}/access", {"password": "secret123"})
|
||||||
|
report("密码验证访问成功", st == 200 and "秘密内容" in d.get("data", {}).get("content", ""), f"(got {st})")
|
||||||
|
|
||||||
|
st, d = req("POST", f"/api/notes/{protected_id}/access", {"password": "wrong"})
|
||||||
|
report("错误密码访问被拒", st == 401, f"(got {st})")
|
||||||
|
|
||||||
|
# 公开无密码笔记
|
||||||
|
public_ok = False
|
||||||
|
st, d = req("GET", f"/api/notes/{note_id}")
|
||||||
|
report("公开接口读取无密码笔记成功", st == 200 and d.get("code") == 0, f"(got {st})")
|
||||||
|
|
||||||
|
print("═══ 4. 回收站(软删除/恢复/清空) ═══")
|
||||||
|
|
||||||
|
# 删除受保护笔记 -> 进入回收站
|
||||||
|
st, d = req("DELETE", f"/admin/api/notes/{protected_id}")
|
||||||
|
report("软删除笔记成功", st == 200 and d.get("code") == 0, f"(got {st})")
|
||||||
|
|
||||||
|
# 前台公开接口应查不到已删除笔记
|
||||||
|
st, d = req("GET", f"/api/notes/{protected_id}")
|
||||||
|
report("已删除笔记公开接口404", st == 404, f"(got {st})")
|
||||||
|
|
||||||
|
st, d = req("GET", "/admin/api/trash")
|
||||||
|
trash = d.get("data", [])
|
||||||
|
report("回收站包含已删笔记", any(t.get("id") == protected_id for t in trash), f"(got {[t.get('id') for t in trash]})")
|
||||||
|
|
||||||
|
# 恢复
|
||||||
|
st, d = req("POST", f"/admin/api/restore/{protected_id}")
|
||||||
|
report("从回收站恢复成功", st == 200 and d.get("code") == 0, f"(got {st})")
|
||||||
|
|
||||||
|
st, d = req("GET", "/admin/api/trash")
|
||||||
|
trash = d.get("data", [])
|
||||||
|
report("恢复后回收站不再包含该笔记", not any(t.get("id") == protected_id for t in trash))
|
||||||
|
|
||||||
|
st, d = req("GET", f"/api/notes/{protected_id}")
|
||||||
|
report("恢复后可再次访问(仍带密码被拒)", st in (401, 403, 200), f"(got {st})")
|
||||||
|
|
||||||
|
# 彻底删除测试
|
||||||
|
st, d = req("POST", "/admin/api/notes", {"title": "待彻底删除", "content": "x", "is_public": True})
|
||||||
|
tmp_id = d.get("data", {}).get("id")
|
||||||
|
req("DELETE", f"/admin/api/notes/{tmp_id}")
|
||||||
|
st, d = req("POST", f"/admin/api/purge/{tmp_id}")
|
||||||
|
report("彻底删除成功", st == 200 and d.get("code") == 0, f"(got {st})")
|
||||||
|
|
||||||
|
st, d = req("GET", "/admin/api/trash")
|
||||||
|
report("彻底删除后回收站无该记录", not any(t.get("id") == tmp_id for t in d.get("data", [])))
|
||||||
|
|
||||||
|
print("═══ 5. 分享链接 ═══")
|
||||||
|
|
||||||
|
# 给测试笔记A创建分享
|
||||||
|
st, d = req("POST", f"/admin/api/notes/{note_id}/share", "expire_hours=24")
|
||||||
|
share_token = d.get("data", {}).get("share_token")
|
||||||
|
report("创建分享链接成功", st == 200 and share_token, f"(got {st} {d})")
|
||||||
|
|
||||||
|
# 通过分享链接访问
|
||||||
|
st, d = req("GET", f"/api/share/{share_token}")
|
||||||
|
report("分享链接可公开访问", st == 200 and d.get("data", {}).get("id") == note_id, f"(got {st})")
|
||||||
|
|
||||||
|
# 分享阅读页
|
||||||
|
st, raw, _ = req("GET", f"/share/{share_token}", raw=True)
|
||||||
|
report("分享阅读页返回HTML", st == 200 and b"<html" in raw.lower(), f"(got {st})")
|
||||||
|
|
||||||
|
# 撤销分享
|
||||||
|
st, d = req("POST", f"/admin/api/notes/{note_id}/revoke-share")
|
||||||
|
report("撤销分享成功", st == 200 and d.get("code") == 0, f"(got {st})")
|
||||||
|
|
||||||
|
st, d = req("GET", f"/api/share/{share_token}")
|
||||||
|
report("撤销后分享链接失效", st == 404, f"(got {st})")
|
||||||
|
|
||||||
|
print("═══ 6. 批量导出 zip ═══")
|
||||||
|
|
||||||
|
st, raw, hdrs = req("GET", "/admin/api/export-all", raw=True)
|
||||||
|
is_zip = False
|
||||||
|
if st == 200:
|
||||||
|
try:
|
||||||
|
zf = zipfile.ZipFile(io.BytesIO(raw))
|
||||||
|
names = zf.namelist()
|
||||||
|
is_zip = True
|
||||||
|
report("批量导出zip包含测试笔记", any("测试笔记" in n for n in names), f"(names={names})")
|
||||||
|
report("批量导出zip含front matter", any("---" in zf.read(n).decode()[:200] for n in names if n.endswith(".md")))
|
||||||
|
except Exception as ex:
|
||||||
|
report("批量导出zip解析", False, f"(err {ex})")
|
||||||
|
report("批量导出返回zip", st == 200 and raw[:2] == b"PK" and is_zip, f"(got {st})")
|
||||||
|
|
||||||
|
print("═══ 7. 单笔记导出 + 图片上传嗅探 ═══")
|
||||||
|
|
||||||
|
st, d = req("GET", f"/admin/api/notes/{note_id}")
|
||||||
|
report("后台读取笔记详情成功", st == 200 and d.get("data", {}).get("id") == note_id)
|
||||||
|
|
||||||
|
st, raw, _ = req("GET", f"/admin/api/export/{note_id}", raw=True)
|
||||||
|
report("单笔记导出Markdown", st == 200 and "测试笔记" in raw.decode('utf-8', 'ignore'), f"(got {st})")
|
||||||
|
|
||||||
|
# 上传伪造文件(伪装成 png 的文本)
|
||||||
|
import uuid
|
||||||
|
fake_bytes = b"<script>alert(1)</script>"
|
||||||
|
# 用 multipart 上传
|
||||||
|
boundary = "----WebKitFormBoundary" + uuid.uuid4().hex
|
||||||
|
body = (
|
||||||
|
f"--{boundary}\r\n"
|
||||||
|
f'Content-Disposition: form-data; name="image"; filename="evil.png"\r\n'
|
||||||
|
f"Content-Type: image/png\r\n\r\n"
|
||||||
|
).encode() + fake_bytes + f"\r\n--{boundary}--\r\n".encode()
|
||||||
|
st, d = req("POST", "/admin/api/upload", data=body, headers={"Content-Type": f"multipart/form-data; boundary={boundary}"}, raw=False)
|
||||||
|
report("伪造图片(非真实图片)被拒", st in (400, 500), f"(got {st} {d})")
|
||||||
|
|
||||||
|
print("═══ 8. 列表/分类/标签/树 ═══")
|
||||||
|
st, d = req("GET", "/api/notes")
|
||||||
|
report("公开列表接口正常", st == 200 and d.get("code") == 0)
|
||||||
|
st, d = req("GET", "/api/categories")
|
||||||
|
report("分类列表正常", st == 200 and "测试" in d.get("data", []))
|
||||||
|
st, d = req("GET", "/api/tags")
|
||||||
|
report("标签列表正常", st == 200)
|
||||||
|
st, d = req("GET", "/api/tree")
|
||||||
|
report("公开树正常", st == 200)
|
||||||
|
|
||||||
|
print(f"\n════ 结果:{PASSED} 通过,{FAILED} 失败 ════")
|
||||||
|
sys.exit(0 if FAILED == 0 else 1)
|
||||||
+275
-7
@@ -317,12 +317,32 @@
|
|||||||
transition: background 0.2s;
|
transition: background 0.2s;
|
||||||
}
|
}
|
||||||
.resize-handle:hover { background: #1a73e8; }
|
.resize-handle:hover { background: #1a73e8; }
|
||||||
|
|
||||||
|
/* ───── 深色模式覆盖 ───── */
|
||||||
|
body.dark { background: #1a1d21; color: #e0e0e0; }
|
||||||
|
body.dark header { background: #20242a; box-shadow: 0 1px 3px rgba(0,0,0,0.4); }
|
||||||
|
body.dark .sidebar { background: #23272e; border-color: #333; }
|
||||||
|
body.dark .content { background: #1a1d21; }
|
||||||
|
body.dark .tree-item:hover { background: #2d323a; }
|
||||||
|
body.dark .tree-item.active { background: #355; color:#7ab8ff; }
|
||||||
|
body.dark .editor-pane, body.dark .preview-pane { background: #1e2228; border-color:#333; }
|
||||||
|
body.dark input, body.dark textarea, body.dark select {
|
||||||
|
background: #24282f; color: #e0e0e0; border-color: #3a3f47;
|
||||||
|
}
|
||||||
|
body.dark .modal-content { background: #24282f; color:#e0e0e0; }
|
||||||
|
body.dark .preview-content code, body.dark .preview-content pre { background:#111; }
|
||||||
|
body.dark .empty-state { color:#888; }
|
||||||
|
body.dark .btn-secondary { background:#2d323a; color:#ccc; border-color:#3a3f47; }
|
||||||
|
body.dark .tree-item .folder { color:#7ab8ff; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<h1>笔记管理后台</h1>
|
<h1>笔记管理后台</h1>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
|
<button class="btn btn-secondary" onclick="toggleDarkMode()" title="切换深色模式" id="darkModeBtn">🌙 深色</button>
|
||||||
|
<button class="btn btn-secondary" onclick="exportAll()" title="批量导出全部笔记为 zip">📦 批量导出</button>
|
||||||
|
<button class="btn btn-secondary" onclick="openTrash()" title="回收站">🗑 回收站</button>
|
||||||
<a href="/" class="btn btn-secondary" target="_blank">前台预览</a>
|
<a href="/" class="btn btn-secondary" target="_blank">前台预览</a>
|
||||||
<button class="btn btn-secondary" onclick="logout()">退出登录</button>
|
<button class="btn btn-secondary" onclick="logout()">退出登录</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -364,6 +384,8 @@
|
|||||||
<input type="text" id="noteTitle" placeholder="笔记标题">
|
<input type="text" id="noteTitle" placeholder="笔记标题">
|
||||||
<button class="btn btn-primary" onclick="saveNote()">保存</button>
|
<button class="btn btn-primary" onclick="saveNote()">保存</button>
|
||||||
<button class="btn btn-secondary" onclick="exportNote()">导出</button>
|
<button class="btn btn-secondary" onclick="exportNote()">导出</button>
|
||||||
|
<button class="btn btn-secondary" onclick="openVersions()" title="版本历史">🕘 历史</button>
|
||||||
|
<button class="btn btn-secondary" onclick="openShare()" title="分享链接">🔗 分享</button>
|
||||||
<button class="btn btn-warning" onclick="document.getElementById('importFile').click()">导入</button>
|
<button class="btn btn-warning" onclick="document.getElementById('importFile').click()">导入</button>
|
||||||
<input type="file" id="importFile" accept=".md" style="display:none" onchange="importNote(this.files[0])">
|
<input type="file" id="importFile" accept=".md" style="display:none" onchange="importNote(this.files[0])">
|
||||||
<button class="btn btn-danger" onclick="confirmDelete()">删除</button>
|
<button class="btn btn-danger" onclick="confirmDelete()">删除</button>
|
||||||
@@ -418,7 +440,7 @@
|
|||||||
<div class="modal" id="deleteModal">
|
<div class="modal" id="deleteModal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<h3>确认删除</h3>
|
<h3>确认删除</h3>
|
||||||
<p id="deleteMessage">确定要删除吗?此操作不可撤销。</p>
|
<p id="deleteMessage">确定要删除吗?删除后可在回收站中恢复。</p>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="btn btn-secondary" onclick="closeDeleteModal()">取消</button>
|
<button class="btn btn-secondary" onclick="closeDeleteModal()">取消</button>
|
||||||
<button class="btn btn-danger" onclick="doDelete()">删除</button>
|
<button class="btn btn-danger" onclick="doDelete()">删除</button>
|
||||||
@@ -426,6 +448,61 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 回收站弹窗 -->
|
||||||
|
<div class="modal" id="trashModal">
|
||||||
|
<div class="modal-content" style="min-width: 560px; max-height: 70vh; display: flex; flex-direction: column;">
|
||||||
|
<h3 style="margin-bottom: 12px;">🗑 回收站 <span style="font-size:13px;color:#999;font-weight:normal;">(删除的笔记会在这里保留,可恢复或彻底清除)</span></h3>
|
||||||
|
<div style="flex:1; overflow-y:auto;" id="trashList"><p style="color:#999;padding:20px;text-align:center;">加载中...</p></div>
|
||||||
|
<div class="modal-actions" style="margin-top:12px;justify-content:space-between;">
|
||||||
|
<button class="btn btn-danger" onclick="emptyTrash()">清空回收站</button>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-secondary" onclick="closeTrash()">关闭</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 版本历史弹窗 -->
|
||||||
|
<div class="modal" id="versionsModal">
|
||||||
|
<div class="modal-content" style="min-width: 560px; max-height: 70vh; display: flex; flex-direction: column;">
|
||||||
|
<h3 style="margin-bottom: 12px;">🕘 版本历史</h3>
|
||||||
|
<div style="flex:1; overflow-y:auto;" id="versionsList"><p style="color:#999;padding:20px;text-align:center;">加载中...</p></div>
|
||||||
|
<div class="modal-actions" style="margin-top:12px;">
|
||||||
|
<button class="btn btn-secondary" onclick="closeVersions()">关闭</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 分享弹窗 -->
|
||||||
|
<div class="modal" id="shareModal">
|
||||||
|
<div class="modal-content" style="min-width: 520px;">
|
||||||
|
<h3 style="margin-bottom: 16px;">🔗 分享链接</h3>
|
||||||
|
<div id="shareNoToken" style="display:block;">
|
||||||
|
<p style="color:#666;font-size:14px;margin-bottom:14px;">生成一个链接,无需登录即可查看该笔记。</p>
|
||||||
|
<label style="font-size:13px;color:#888;">可选:过期时间</label>
|
||||||
|
<select id="shareExpire" style="width:100%;padding:8px;margin:6px 0 14px;border:1px solid #ccc;border-radius:6px;">
|
||||||
|
<option value="0">永久有效</option>
|
||||||
|
<option value="24">24 小时</option>
|
||||||
|
<option value="72">3 天</option>
|
||||||
|
<option value="168">7 天</option>
|
||||||
|
<option value="720">30 天</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-primary" style="width:100%;" onclick="createShare()">生成分享链接</button>
|
||||||
|
</div>
|
||||||
|
<div id="shareHasToken" style="display:none;">
|
||||||
|
<label style="font-size:13px;color:#888;">分享链接</label>
|
||||||
|
<div style="display:flex;gap:8px;margin:6px 0 12px;">
|
||||||
|
<input type="text" id="shareUrl" readonly style="flex:1;padding:8px;border:1px solid #ccc;border-radius:6px;background:#f5f5f5;">
|
||||||
|
<button class="btn btn-secondary" onclick="copyShareUrl()">复制</button>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-danger" style="width:100%;" onclick="revokeShare()">撤销分享</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions" style="margin-top:16px;">
|
||||||
|
<button class="btn btn-secondary" onclick="closeShare()">关闭</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -574,7 +651,7 @@
|
|||||||
// 选择项目
|
// 选择项目
|
||||||
async function selectItem(id) {
|
async function selectItem(id) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API}/notes/${id}`);
|
const res = await fetch(`${ADMIN_API}/notes/${id}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.code === 0) {
|
if (data.code === 0) {
|
||||||
currentItem = data.data;
|
currentItem = data.data;
|
||||||
@@ -660,14 +737,14 @@
|
|||||||
let res, data;
|
let res, data;
|
||||||
if (editId) {
|
if (editId) {
|
||||||
// 更新
|
// 更新
|
||||||
res = await fetch(`${API}/notes/${editId}`, {
|
res = await fetch(`${ADMIN_API}/notes/${editId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ title: name })
|
body: JSON.stringify({ title: name })
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// 新建
|
// 新建
|
||||||
res = await fetch(`${API}/notes`, {
|
res = await fetch(`${ADMIN_API}/notes`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ title: name, is_folder: true, parent_id: parentId })
|
body: JSON.stringify({ title: name, is_folder: true, parent_id: parentId })
|
||||||
@@ -749,14 +826,14 @@
|
|||||||
let res, data;
|
let res, data;
|
||||||
if (currentItem && currentItem.id && !currentItem.is_folder) {
|
if (currentItem && currentItem.id && !currentItem.is_folder) {
|
||||||
// 更新
|
// 更新
|
||||||
res = await fetch(`${API}/notes/${currentItem.id}`, {
|
res = await fetch(`${ADMIN_API}/notes/${currentItem.id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// 新建
|
// 新建
|
||||||
res = await fetch(`${API}/notes`, {
|
res = await fetch(`${ADMIN_API}/notes`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
@@ -837,7 +914,7 @@
|
|||||||
if (!currentItem || !currentItem.id) return;
|
if (!currentItem || !currentItem.id) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API}/notes/${currentItem.id}`, { method: 'DELETE' });
|
const res = await fetch(`${ADMIN_API}/notes/${currentItem.id}`, { method: 'DELETE' });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
if (data.code === 0) {
|
if (data.code === 0) {
|
||||||
@@ -1031,6 +1108,197 @@
|
|||||||
document.body.style.userSelect = '';
|
document.body.style.userSelect = '';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══════════ 增强功能:深色模式 / 批量导出 / 回收站 / 版本历史 / 分享 ═══════════
|
||||||
|
|
||||||
|
// ── 深色模式 ──
|
||||||
|
function toggleDarkMode() {
|
||||||
|
const isDark = document.body.classList.toggle('dark');
|
||||||
|
localStorage.setItem('nm_dark', isDark ? '1' : '0');
|
||||||
|
document.getElementById('darkModeBtn').textContent = isDark ? '☀️ 白天' : '🌙 深色';
|
||||||
|
}
|
||||||
|
function initDarkMode() {
|
||||||
|
const dark = localStorage.getItem('nm_dark') === '1';
|
||||||
|
if (dark) {
|
||||||
|
document.body.classList.add('dark');
|
||||||
|
document.getElementById('darkModeBtn').textContent = '☀️ 白天';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 批量导出全部笔记为 zip ──
|
||||||
|
async function exportAll() {
|
||||||
|
showToast('正在导出全部笔记...', 'info');
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${ADMIN_API}/export-all`);
|
||||||
|
if (!res.ok) { showToast('导出失败', 'error'); return; }
|
||||||
|
const blob = await res.blob();
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = URL.createObjectURL(blob);
|
||||||
|
a.download = 'notes-export.zip';
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(a.href);
|
||||||
|
showToast('导出成功', 'success');
|
||||||
|
} catch (e) {
|
||||||
|
showToast('导出失败', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 回收站 ──
|
||||||
|
async function openTrash() {
|
||||||
|
const modal = document.getElementById('trashModal');
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
document.getElementById('trashList').innerHTML = '<p style="color:#999;padding:20px;text-align:center;">加载中...</p>';
|
||||||
|
const res = await fetch(`${ADMIN_API}/trash`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.code === 0) {
|
||||||
|
renderTrash(data.data);
|
||||||
|
} else {
|
||||||
|
document.getElementById('trashList').innerHTML = `<p style="color:#d32f2f;padding:20px;text-align:center;">${escapeHtml(data.message || '加载失败')}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function closeTrash() {
|
||||||
|
document.getElementById('trashModal').style.display = 'none';
|
||||||
|
}
|
||||||
|
function renderTrash(items) {
|
||||||
|
const box = document.getElementById('trashList');
|
||||||
|
if (!items || !items.length) {
|
||||||
|
box.innerHTML = '<p style="color:#999;padding:30px;text-align:center;">回收站是空的</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rows = items.map(item => {
|
||||||
|
const icon = item.is_folder ? '📁' : '📄';
|
||||||
|
return `<div style="display:flex;align-items:center;gap:8px;padding:10px;border-bottom:1px solid #eee;">
|
||||||
|
<span>${icon}</span>
|
||||||
|
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escapeHtml(item.title || '(无标题)')}</span>
|
||||||
|
<span style="font-size:12px;color:#999;">${item.is_folder ? '目录' : ''}</span>
|
||||||
|
<button class="btn btn-secondary" style="font-size:12px;padding:4px 10px;" onclick="restoreTrashItem(${item.id})">恢复</button>
|
||||||
|
<button class="btn btn-danger" style="font-size:12px;padding:4px 10px;" onclick="purgeTrashItem(${item.id})">彻底删除</button>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
box.innerHTML = rows;
|
||||||
|
}
|
||||||
|
async function restoreTrashItem(id) {
|
||||||
|
const res = await fetch(`${ADMIN_API}/restore/${id}`, { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.code === 0) { showToast('已恢复', 'success'); openTrash(); loadTree(); }
|
||||||
|
else showToast(data.message || '恢复失败', 'error');
|
||||||
|
}
|
||||||
|
async function purgeTrashItem(id) {
|
||||||
|
if (!confirm('确定彻底删除这条记录吗?此操作不可恢复!')) return;
|
||||||
|
const res = await fetch(`${ADMIN_API}/purge/${id}`, { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.code === 0) { showToast('已彻底删除', 'success'); openTrash(); }
|
||||||
|
else showToast(data.message || '删除失败', 'error');
|
||||||
|
}
|
||||||
|
async function emptyTrash() {
|
||||||
|
if (!confirm('确定清空回收站吗?所有记录将被彻底删除,不可恢复!')) return;
|
||||||
|
const res = await fetch(`${ADMIN_API}/empty-trash`, { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.code === 0) { showToast('回收站已清空', 'success'); openTrash(); }
|
||||||
|
else showToast(data.message || '清空失败', 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 版本历史 ──
|
||||||
|
async function openVersions() {
|
||||||
|
if (!currentItem || !currentItem.id) { showToast('请先选择一篇笔记', 'error'); return; }
|
||||||
|
if (currentItem.is_folder) { showToast('目录没有版本历史', 'error'); return; }
|
||||||
|
const modal = document.getElementById('versionsModal');
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
document.getElementById('versionsList').innerHTML = '<p style="color:#999;padding:20px;text-align:center;">加载中...</p>';
|
||||||
|
const res = await fetch(`${ADMIN_API}/notes/${currentItem.id}/versions`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.code === 0) renderVersions(data.data);
|
||||||
|
else document.getElementById('versionsList').innerHTML = `<p style="color:#d32f2f;padding:20px;text-align:center;">${escapeHtml(data.message || '加载失败')}</p>`;
|
||||||
|
}
|
||||||
|
function closeVersions() {
|
||||||
|
document.getElementById('versionsModal').style.display = 'none';
|
||||||
|
}
|
||||||
|
function renderVersions(versions) {
|
||||||
|
const box = document.getElementById('versionsList');
|
||||||
|
if (!versions || !versions.length) {
|
||||||
|
box.innerHTML = '<p style="color:#999;padding:30px;text-align:center;">暂无历史版本</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rows = versions.map((v, idx) => {
|
||||||
|
const d = new Date(v.created_at);
|
||||||
|
const preview = (v.content || '').replace(/[#*`>\-\[\]]/g, '').slice(0, 80);
|
||||||
|
return `<div style="padding:10px;border-bottom:1px solid #eee;">
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;">
|
||||||
|
<strong>${idx === 0 ? '当前版本' : '版本 ' + (versions.length - idx)}</strong>
|
||||||
|
<span style="font-size:12px;color:#999;">${d.toLocaleString('zh-CN')}</span>
|
||||||
|
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;color:#777;">${escapeHtml(preview)}</span>
|
||||||
|
${idx !== 0 ? `<button class="btn btn-secondary" style="font-size:12px;padding:4px 10px;" onclick="restoreVersion(${v.id})">恢复此版本</button>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
box.innerHTML = rows;
|
||||||
|
}
|
||||||
|
async function restoreVersion(versionId) {
|
||||||
|
if (!confirm('确定恢复到该版本吗?当前内容会先保存为新版本。')) return;
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('version_id', versionId);
|
||||||
|
const res = await fetch(`${ADMIN_API}/notes/${currentItem.id}/restore-version`, { method: 'POST', body: fd });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.code === 0) {
|
||||||
|
showToast('已恢复', 'success');
|
||||||
|
closeVersions();
|
||||||
|
await selectItem(currentItem.id);
|
||||||
|
loadTree();
|
||||||
|
} else showToast(data.message || '恢复失败', 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 分享 ──
|
||||||
|
function openShare() {
|
||||||
|
if (!currentItem || !currentItem.id) { showToast('请先选择一篇笔记', 'error'); return; }
|
||||||
|
if (currentItem.is_folder) { showToast('目录不能分享', 'error'); return; }
|
||||||
|
const modal = document.getElementById('shareModal');
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
if (currentItem.share_token) {
|
||||||
|
document.getElementById('shareNoToken').style.display = 'none';
|
||||||
|
document.getElementById('shareHasToken').style.display = 'block';
|
||||||
|
document.getElementById('shareUrl').value = location.origin + '/share/' + currentItem.share_token;
|
||||||
|
} else {
|
||||||
|
document.getElementById('shareNoToken').style.display = 'block';
|
||||||
|
document.getElementById('shareHasToken').style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function closeShare() {
|
||||||
|
document.getElementById('shareModal').style.display = 'none';
|
||||||
|
}
|
||||||
|
async function createShare() {
|
||||||
|
const expire = document.getElementById('shareExpire').value;
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('expire_hours', expire);
|
||||||
|
const res = await fetch(`${ADMIN_API}/notes/${currentItem.id}/share`, { method: 'POST', body: fd });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.code === 0) {
|
||||||
|
showToast('分享链接已生成', 'success');
|
||||||
|
currentItem.share_token = data.data.share_token;
|
||||||
|
openShare();
|
||||||
|
} else showToast(data.message || '生成失败', 'error');
|
||||||
|
}
|
||||||
|
async function revokeShare() {
|
||||||
|
const res = await fetch(`${ADMIN_API}/notes/${currentItem.id}/revoke-share`, { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.code === 0) {
|
||||||
|
showToast('已撤销分享', 'success');
|
||||||
|
currentItem.share_token = '';
|
||||||
|
openShare();
|
||||||
|
} else showToast(data.message || '操作失败', 'error');
|
||||||
|
}
|
||||||
|
function copyShareUrl() {
|
||||||
|
const url = document.getElementById('shareUrl').value;
|
||||||
|
if (navigator.clipboard) {
|
||||||
|
navigator.clipboard.writeText(url).then(() => showToast('链接已复制', 'success')).catch(() => showToast('复制失败', 'error'));
|
||||||
|
} else {
|
||||||
|
document.getElementById('shareUrl').select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
showToast('链接已复制', 'success');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化深色模式
|
||||||
|
initDarkMode();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>分享的笔记 - 云笔记</title>
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css">
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: #fafafa; color: #333; line-height: 1.7;
|
||||||
|
}
|
||||||
|
.container { max-width: 860px; margin: 40px auto; background: #fff; padding: 48px; border-radius: 10px; box-shadow: 0 1px 6px rgba(0,0,0,0.08); }
|
||||||
|
.badge { display: inline-block; background: #e8f0fe; color: #1a73e8; padding: 4px 12px; border-radius: 20px; font-size: 13px; margin-bottom: 16px; }
|
||||||
|
h1 { font-size: 28px; margin-bottom: 12px; color: #1a1a1a; }
|
||||||
|
.meta { color: #888; font-size: 14px; margin-bottom: 24px; padding-bottom: 16px; border-bottom: 1px solid #eee; }
|
||||||
|
.tag { display: inline-block; background: #f1f3f4; color: #5f6368; padding: 2px 10px; border-radius: 12px; font-size: 13px; margin-right: 6px; }
|
||||||
|
.note-content { word-wrap: break-word; }
|
||||||
|
.note-content h1,.note-content h2,.note-content h3,.note-content h4 { margin: 20px 0 10px; color: #1a1a1a; }
|
||||||
|
.note-content p { margin: 10px 0; }
|
||||||
|
.note-content pre { background: #f6f8fa; padding: 14px; border-radius: 6px; overflow-x: auto; }
|
||||||
|
.note-content code { background: #f6f8fa; padding: 2px 5px; border-radius: 3px; font-size: 14px; }
|
||||||
|
.note-content pre code { background: none; padding: 0; }
|
||||||
|
.note-content img { max-width: 100%; }
|
||||||
|
.note-content blockquote { border-left: 4px solid #ddd; padding-left: 14px; color: #666; margin: 12px 0; }
|
||||||
|
.note-content table { border-collapse: collapse; margin: 12px 0; }
|
||||||
|
.note-content th,.note-content td { border: 1px solid #ddd; padding: 8px 12px; }
|
||||||
|
.note-content a { color: #1a73e8; }
|
||||||
|
.password-prompt { text-align: center; padding: 60px 20px; }
|
||||||
|
.password-prompt input { padding: 10px 14px; border: 1px solid #ccc; border-radius: 6px; width: 260px; font-size: 14px; }
|
||||||
|
.password-prompt button { padding: 10px 24px; background: #1a73e8; color: #fff; border: none; border-radius: 6px; font-size: 14px; cursor: pointer; margin-left: 8px; }
|
||||||
|
.footer { text-align: center; color: #bbb; font-size: 13px; margin: 24px 0 40px; }
|
||||||
|
.hidden { display: none; }
|
||||||
|
.mermaid { text-align: center; margin: 16px 0; }
|
||||||
|
.task-done { text-decoration: line-through; color: #888; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<span class="badge">📄 分享的笔记</span>
|
||||||
|
<div id="noteDetail" class="hidden">
|
||||||
|
<h1 id="noteTitle"></h1>
|
||||||
|
<div class="meta">
|
||||||
|
<span>分类:<span id="noteCategory">未分类</span></span>
|
||||||
|
<span style="margin-left:16px" id="noteDate"></span>
|
||||||
|
<div style="margin-top:10px" id="noteTags"></div>
|
||||||
|
</div>
|
||||||
|
<div class="note-content" id="noteContent"></div>
|
||||||
|
</div>
|
||||||
|
<div class="password-prompt" id="passwordPrompt" style="display:none">
|
||||||
|
<h3>🔒 该笔记已加密</h3>
|
||||||
|
<p style="color:#888;margin:10px 0 20px">请输入访问密码查看内容</p>
|
||||||
|
<input type="password" id="passwordInput" placeholder="输入访问密码">
|
||||||
|
<button onclick="accessWithPassword()">查看</button>
|
||||||
|
<p id="pwdError" style="color:#d32f2f;margin-top:12px"></p>
|
||||||
|
</div>
|
||||||
|
<div id="errorBox" class="password-prompt" style="display:none">
|
||||||
|
<h3>😕 无法访问</h3>
|
||||||
|
<p id="errorMsg" style="color:#888;margin-top:10px"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="footer">Powered by 云笔记</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const TOKEN = '{{.token}}';
|
||||||
|
const API = '/api/share/' + TOKEN;
|
||||||
|
|
||||||
|
async function load(password) {
|
||||||
|
let url = API;
|
||||||
|
if (password) url += '?password=' + encodeURIComponent(password);
|
||||||
|
const res = await fetch(url);
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.code === 0) {
|
||||||
|
render(data.data);
|
||||||
|
} else if (res.status === 401) {
|
||||||
|
document.getElementById('passwordPrompt').style.display = 'block';
|
||||||
|
document.getElementById('passwordPrompt').style.display = 'flex';
|
||||||
|
} else {
|
||||||
|
document.getElementById('errorBox').style.display = 'block';
|
||||||
|
document.getElementById('errorMsg').textContent = data.message || '笔记不存在或链接已失效';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function accessWithPassword() {
|
||||||
|
const pwd = document.getElementById('passwordInput').value;
|
||||||
|
document.getElementById('pwdError').textContent = '';
|
||||||
|
let url = API + '?password=' + encodeURIComponent(pwd);
|
||||||
|
const res = await fetch(url);
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.code === 0) {
|
||||||
|
document.getElementById('passwordPrompt').style.display = 'none';
|
||||||
|
render(data.data);
|
||||||
|
} else {
|
||||||
|
document.getElementById('pwdError').textContent = '密码错误,请重试';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(note) {
|
||||||
|
document.getElementById('noteDetail').classList.remove('hidden');
|
||||||
|
document.getElementById('noteTitle').textContent = note.title;
|
||||||
|
if (note.category) document.getElementById('noteCategory').textContent = note.category;
|
||||||
|
if (note.created_at) {
|
||||||
|
const d = new Date(note.created_at);
|
||||||
|
document.getElementById('noteDate').textContent = '发布于 ' + d.toLocaleString('zh-CN');
|
||||||
|
}
|
||||||
|
const tagsBox = document.getElementById('noteTags');
|
||||||
|
try {
|
||||||
|
const tags = JSON.parse(note.tags || '[]');
|
||||||
|
if (Array.isArray(tags) && tags.length) {
|
||||||
|
tagsBox.innerHTML = tags.map(t => `<span class="tag">#${escapeHtml(t)}</span>`).join('');
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
document.getElementById('noteContent').innerHTML = renderMarkdown(note.content || '');
|
||||||
|
// 高亮代码
|
||||||
|
document.querySelectorAll('pre code').forEach(block => {
|
||||||
|
try { hljs.highlightBlock(block); } catch(e){}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 简单 Markdown 渲染(支持代码块、标题、列表、表格、链接、图片、加粗、待办清单)
|
||||||
|
function renderMarkdown(text) {
|
||||||
|
let html = escapeHtml(text);
|
||||||
|
// 代码块
|
||||||
|
html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, function(m, lang, code) {
|
||||||
|
return '<pre><code class="language-' + (lang || '') + '">' + code + '</code></pre>';
|
||||||
|
});
|
||||||
|
// 行内代码
|
||||||
|
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||||
|
// 待办清单
|
||||||
|
html = html.replace(/\[ \] /g, '☐ ');
|
||||||
|
html = html.replace(/\[x\] /g, '☑ ');
|
||||||
|
// 标题
|
||||||
|
html = html.replace(/^### (.*)$/gm, '<h3>$1</h3>');
|
||||||
|
html = html.replace(/^## (.*)$/gm, '<h2>$1</h2>');
|
||||||
|
html = html.replace(/^# (.*)$/gm, '<h1>$1</h1>');
|
||||||
|
// 图片
|
||||||
|
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1">');
|
||||||
|
// 链接
|
||||||
|
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
|
||||||
|
// 加粗
|
||||||
|
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||||||
|
// 引用
|
||||||
|
html = html.replace(/^> (.*)$/gm, '<blockquote>$1</blockquote>');
|
||||||
|
// 无序列表
|
||||||
|
html = html.replace(/^- (.*)$/gm, '<li>$1</li>');
|
||||||
|
html = html.replace(/(<li>[\s\S]*?<\/li>)/g, function(m){ return m.includes('<li></li>') ? m : '<ul>'+m+'</ul>'; });
|
||||||
|
// 表格
|
||||||
|
html = html.replace(/\|(.+)\|\n\|[\s-|]+\|\n((?:\|[^|]+\|\n?)*)/g, function(m, head, body) {
|
||||||
|
const hd = head.split('|').filter((x,i)=>i!==0 && i!==head.split('|').length-1).map(c=>`<th>${c.trim()}</th>`).join('');
|
||||||
|
const rows = body.split('\n').filter(r=>r.trim()).map(r => {
|
||||||
|
const cells = r.split('|').filter((x,i)=>i!==0 && i!==r.split('|').length-1).map(c=>`<td>${c.trim()}</td>`).join('');
|
||||||
|
return `<tr>${cells}</tr>`;
|
||||||
|
}).join('');
|
||||||
|
return `<table><thead><tr>${hd}</tr></thead><tbody>${rows}</tbody></table>`;
|
||||||
|
});
|
||||||
|
// 段落
|
||||||
|
html = html.replace(/\n{2,}/g, '</p><p>');
|
||||||
|
html = '<p>' + html + '</p>';
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
load();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+96
-7
@@ -7,6 +7,15 @@
|
|||||||
<!-- Highlight.js 代码高亮 -->
|
<!-- Highlight.js 代码高亮 -->
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css">
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
|
||||||
|
<script>
|
||||||
|
// 初始化 Mermaid(延迟到 DOM 加载)
|
||||||
|
window.addEventListener('load', function() {
|
||||||
|
try {
|
||||||
|
mermaid.initialize({ startOnLoad: false, theme: 'default', securityLevel: 'loose' });
|
||||||
|
} catch(e) { console.warn('mermaid init failed', e); }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
<style>
|
<style>
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
body {
|
body {
|
||||||
@@ -14,6 +23,23 @@
|
|||||||
background: #fafafa;
|
background: #fafafa;
|
||||||
color: #333;
|
color: #333;
|
||||||
}
|
}
|
||||||
|
/* 待办清单 */
|
||||||
|
.task-item { list-style: none; margin: 2px 0; }
|
||||||
|
.task-checkbox { margin-right: 6px; vertical-align: middle; width: 15px; height: 15px; }
|
||||||
|
.task-done { text-decoration: line-through; color: #888; }
|
||||||
|
.mermaid { text-align: center; margin: 16px 0; overflow-x: auto; }
|
||||||
|
/* ───── 深色模式覆盖 ───── */
|
||||||
|
body.dark { background: #1a1d21; color: #e0e0e0; }
|
||||||
|
body.dark header { background: #20242a; box-shadow: 0 1px 3px rgba(0,0,0,0.4); }
|
||||||
|
body.dark .sidebar { background: #23272e; border-color: #333; }
|
||||||
|
body.dark .main { background: #1a1d21; }
|
||||||
|
body.dark .search-box { background: #2d323a; }
|
||||||
|
body.dark .search-box input { color: #e0e0e0; }
|
||||||
|
body.dark .note-detail { background: #1e2228; }
|
||||||
|
body.dark .note-content pre, body.dark .note-content code { background: #111; }
|
||||||
|
body.dark .tree-item:hover { background: #2d323a; }
|
||||||
|
body.dark .toc-sidebar { border-color: #333; }
|
||||||
|
body.dark a { color: #7ab8ff; }
|
||||||
header {
|
header {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
padding: 16px 24px;
|
padding: 16px 24px;
|
||||||
@@ -426,6 +452,7 @@
|
|||||||
<input type="text" id="searchInput" placeholder="搜索笔记..." onkeyup="handleSearch(event)">
|
<input type="text" id="searchInput" placeholder="搜索笔记..." onkeyup="handleSearch(event)">
|
||||||
</div>
|
</div>
|
||||||
<a href="/admin/" style="color: #666; text-decoration: none; font-size: 14px;">管理</a>
|
<a href="/admin/" style="color: #666; text-decoration: none; font-size: 14px;">管理</a>
|
||||||
|
<button onclick="toggleSiteDark()" id="darkBtn" style="background:none;border:1px solid #ccc;border-radius:20px;padding:5px 12px;font-size:13px;cursor:pointer;color:#666;">🌙 深色</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -471,6 +498,7 @@
|
|||||||
<div class="meta">
|
<div class="meta">
|
||||||
<span id="noteCategory"></span>
|
<span id="noteCategory"></span>
|
||||||
<span id="noteDate"></span>
|
<span id="noteDate"></span>
|
||||||
|
<span id="noteStats" style="color:#999;font-size:12px;margin-left:8px;"></span>
|
||||||
<div class="tags" id="noteTags"></div>
|
<div class="tags" id="noteTags"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="note-content" id="noteContent"></div>
|
<div class="note-content" id="noteContent"></div>
|
||||||
@@ -659,8 +687,20 @@
|
|||||||
const renderedContent = renderMarkdown(currentNote.content || '');
|
const renderedContent = renderMarkdown(currentNote.content || '');
|
||||||
document.getElementById('noteContent').innerHTML = renderedContent;
|
document.getElementById('noteContent').innerHTML = renderedContent;
|
||||||
|
|
||||||
|
// 字数统计
|
||||||
|
const statsEl = document.getElementById('noteStats');
|
||||||
|
if (statsEl) {
|
||||||
|
const text = (currentNote.content || '').replace(/\s+/g, '');
|
||||||
|
const words = text.length;
|
||||||
|
const readMin = Math.max(1, Math.ceil(words / 400));
|
||||||
|
statsEl.textContent = `${words} 字 · 约 ${readMin} 分钟阅读`;
|
||||||
|
}
|
||||||
|
|
||||||
// 生成目录
|
// 生成目录
|
||||||
generateTOC(currentNote.content || '');
|
generateTOC(currentNote.content || '');
|
||||||
|
|
||||||
|
// 渲染 mermaid 图表(异步)
|
||||||
|
setTimeout(renderMermaid, 50);
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateTOC(content) {
|
function generateTOC(content) {
|
||||||
@@ -803,6 +843,29 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 简单的 Markdown 渲染
|
// 简单的 Markdown 渲染
|
||||||
|
// Mermaid 图表渲染支持
|
||||||
|
let mmdCounter = 0;
|
||||||
|
let mmdItems = [];
|
||||||
|
|
||||||
|
// 渲染页面中的 mermaid 图表
|
||||||
|
function renderMermaid() {
|
||||||
|
if (typeof mermaid === 'undefined' || !mmdItems.length) return;
|
||||||
|
const items = mmdItems.splice(0, mmdItems.length);
|
||||||
|
items.forEach(item => {
|
||||||
|
const el = document.getElementById(item.uid);
|
||||||
|
if (!el) return;
|
||||||
|
try {
|
||||||
|
mermaid.render(item.uid, item.code, el).then(({svg}) => {
|
||||||
|
el.innerHTML = svg;
|
||||||
|
}).catch(() => {
|
||||||
|
el.innerHTML = '<pre style="color:#c0392b;background:#fdf0f0;padding:12px;border-radius:6px;">Mermaid 渲染失败(请检查图表语法)</pre>';
|
||||||
|
});
|
||||||
|
} catch(e) {
|
||||||
|
el.innerHTML = '<pre style="color:#c0392b;background:#fdf0f0;padding:12px;border-radius:6px;">Mermaid 渲染失败(请检查图表语法)</pre>';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function renderMarkdown(text) {
|
function renderMarkdown(text) {
|
||||||
if (!text) return '<p style="color: #999;">无内容</p>';
|
if (!text) return '<p style="color: #999;">无内容</p>';
|
||||||
|
|
||||||
@@ -817,7 +880,6 @@
|
|||||||
index++;
|
index++;
|
||||||
return placeholder;
|
return placeholder;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 用占位符保护图片,避免被后续处理影响
|
// 用占位符保护图片,避免被后续处理影响
|
||||||
const imgPlaceholders = [];
|
const imgPlaceholders = [];
|
||||||
text = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function(match, alt, src) {
|
text = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function(match, alt, src) {
|
||||||
@@ -850,13 +912,19 @@
|
|||||||
// 处理行内代码
|
// 处理行内代码
|
||||||
text = text.replace(/`([^`]+)`/g, '<code>$1</code>');
|
text = text.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||||
|
|
||||||
// 恢复代码块并应用高亮
|
// 恢复代码块并应用高亮(mermaid 代码块渲染为图表)
|
||||||
codeBlocks.forEach((block, i) => {
|
codeBlocks.forEach((block, i) => {
|
||||||
const highlighted = hljs.highlightAuto(block.code, block.lang !== 'plaintext' ? [block.lang] : undefined).value;
|
let rendered;
|
||||||
text = text.replace(
|
if (block.lang === 'mermaid') {
|
||||||
`__CODE_BLOCK_${i}__`,
|
// 生成 mermaid 图表占位
|
||||||
`<pre><code class="hljs language-${block.lang}">${highlighted}</code></pre>`
|
const uid = 'mmd_' + (++mmdCounter);
|
||||||
);
|
mmdItems.push({ uid: uid, code: block.code });
|
||||||
|
rendered = `<div class="mermaid" id="${uid}">${block.code}</div>`;
|
||||||
|
} else {
|
||||||
|
const highlighted = hljs.highlightAuto(block.code, block.lang !== 'plaintext' ? [block.lang] : undefined).value;
|
||||||
|
rendered = `<pre><code class="hljs language-${block.lang}">${highlighted}</code></pre>`;
|
||||||
|
}
|
||||||
|
text = text.replace(`__CODE_BLOCK_${i}__`, rendered);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 标题(带 id 用于目录导航)
|
// 标题(带 id 用于目录导航)
|
||||||
@@ -881,6 +949,13 @@
|
|||||||
// 删除线
|
// 删除线
|
||||||
text = text.replace(/~~(.+?)~~/g, '<del>$1</del>');
|
text = text.replace(/~~(.+?)~~/g, '<del>$1</del>');
|
||||||
|
|
||||||
|
// 待办清单(- [ ] / - [x])
|
||||||
|
text = text.replace(/^[-*] \[([ xX])\] (.*$)/gm, (m, checked, content) => {
|
||||||
|
const state = (checked === 'x' || checked === 'X') ? 'checked' : '';
|
||||||
|
const cls = state ? 'task-done' : '';
|
||||||
|
return `<li class="task-item"><input type="checkbox" class="task-checkbox" ${state} disabled> <span class="${cls}">${content}</span></li>`;
|
||||||
|
});
|
||||||
|
|
||||||
// 引用
|
// 引用
|
||||||
text = text.replace(/^> (.*$)/gm, '<blockquote>$1</blockquote>');
|
text = text.replace(/^> (.*$)/gm, '<blockquote>$1</blockquote>');
|
||||||
|
|
||||||
@@ -898,7 +973,21 @@
|
|||||||
return `<p>${text}</p>`;
|
return `<p>${text}</p>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 深色模式切换(前台)
|
||||||
|
function toggleSiteDark() {
|
||||||
|
const isDark = document.body.classList.toggle('dark');
|
||||||
|
localStorage.setItem('nm_dark', isDark ? '1' : '0');
|
||||||
|
document.getElementById('darkBtn').textContent = isDark ? '☀️ 白天' : '🌙 深色';
|
||||||
|
}
|
||||||
|
function initSiteDark() {
|
||||||
|
if (localStorage.getItem('nm_dark') === '1') {
|
||||||
|
document.body.classList.add('dark');
|
||||||
|
document.getElementById('darkBtn').textContent = '☀️ 白天';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
init();
|
init();
|
||||||
|
initSiteDark();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user