diff --git a/API.md b/API.md index 2ac2c1b..83fd868 100644 --- a/API.md +++ b/API.md @@ -583,3 +583,76 @@ await fetch('/api/notes', { 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]` 可勾选清单 +- **字数统计**:前台笔记详情显示字数与预估阅读时长 +- **回收站/版本历史/分享**:后台工具栏按钮 + 面板 diff --git a/deploy/note-manager.service b/deploy/note-manager.service new file mode 100644 index 0000000..d54911a --- /dev/null +++ b/deploy/note-manager.service @@ -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 diff --git a/go.mod b/go.mod index 4642c85..8d77e27 100644 --- a/go.mod +++ b/go.mod @@ -1,13 +1,19 @@ 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 ( github.com/bytedance/sonic v1.9.1 // 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-gonic/gin v1.9.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/validator/v10 v10.14.0 // indirect @@ -25,12 +31,9 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.11 // indirect golang.org/x/arch v0.3.0 // indirect - golang.org/x/crypto v0.9.0 // indirect - golang.org/x/net v0.10.0 // indirect - golang.org/x/sys v0.8.0 // indirect - golang.org/x/text v0.9.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect google.golang.org/protobuf v1.30.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - gorm.io/driver/sqlite v1.5.6 // indirect - gorm.io/gorm v1.25.7 // indirect ) diff --git a/go.sum b/go.sum index 3505b8c..08af26f 100644 --- a/go.sum +++ b/go.sum @@ -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/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.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/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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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.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/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= 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= diff --git a/handler/admin_handler.go b/handler/admin_handler.go index e5d7f8d..fb67dd1 100644 --- a/handler/admin_handler.go +++ b/handler/admin_handler.go @@ -5,6 +5,7 @@ import ( "github.com/gin-gonic/gin" "note-manager/config" + "note-manager/middleware" "note-manager/service" ) @@ -19,7 +20,7 @@ func NewAdminHandler(noteSvc *service.NoteService, cfg *config.Config) *AdminHan return &AdminHandler{noteSvc: noteSvc, config: cfg} } -// Login 登录页面 +// LoginPage 登录页面 func (h *AdminHandler) LoginPage(c *gin.Context) { c.HTML(http.StatusOK, "login.html", gin.H{ "title": "后台管理登录", @@ -30,8 +31,11 @@ func (h *AdminHandler) LoginPage(c *gin.Context) { func (h *AdminHandler) Login(c *gin.Context) { password := c.PostForm("password") if password == h.config.AdminPass { - // 设置 cookie,有效期 7 天 - c.SetCookie("admin_token", "authenticated", 7*24*3600, "/", "", false, true) + // 生成随机会话 token + token, _ := middleware.NewSessionToken() + // 通过环境变量判断是否启用 HTTPS(生产建议配置) + secure := c.Request.TLS != nil + middleware.SetAuthCookie(c, token, secure) c.JSON(http.StatusOK, gin.H{ "code": 0, "message": "登录成功", @@ -46,7 +50,8 @@ func (h *AdminHandler) Login(c *gin.Context) { // Logout 登出 func (h *AdminHandler) Logout(c *gin.Context) { - c.SetCookie("admin_token", "", -1, "/", "", false, true) + middleware.RevokeSession(middleware.GetAuthToken(c)) + middleware.ClearAuthCookie(c) c.JSON(http.StatusOK, gin.H{ "code": 0, "message": "已退出登录", @@ -55,8 +60,8 @@ func (h *AdminHandler) Logout(c *gin.Context) { // CheckAuth 检查是否已登录 func (h *AdminHandler) CheckAuth(c *gin.Context) { - token, err := c.Cookie("admin_token") - if err == nil && token == "authenticated" { + token := middleware.GetAuthToken(c) + if middleware.IsValidSession(token) { c.JSON(http.StatusOK, gin.H{ "code": 0, "message": "已登录", @@ -77,8 +82,8 @@ func (h *AdminHandler) CheckAuth(c *gin.Context) { // IndexPage 后台管理首页 func (h *AdminHandler) IndexPage(c *gin.Context) { - token, err := c.Cookie("admin_token") - if err != nil || token != "authenticated" { + token := middleware.GetAuthToken(c) + if !middleware.IsValidSession(token) { c.Redirect(http.StatusFound, "/admin/login") return } diff --git a/handler/image_handler.go b/handler/image_handler.go index 737640e..1189b62 100644 --- a/handler/image_handler.go +++ b/handler/image_handler.go @@ -1,6 +1,8 @@ package handler import ( + "crypto/rand" + "encoding/hex" "fmt" "net/http" "os" @@ -8,6 +10,7 @@ import ( "strings" "time" + "github.com/gabriel-vasile/mimetype" "github.com/gin-gonic/gin" ) @@ -26,6 +29,27 @@ func (h *ImageHandler) Init() error { return os.MkdirAll(h.uploadDir, 0755) } +// 允许的图片 MIME 类型(基于内容嗅探,而非仅扩展名) +var allowedImageTypes = map[string]bool{ + "image/jpeg": true, + "image/png": true, + "image/gif": true, + "image/webp": true, + "image/bmp": true, + "image/svg+xml": false, // SVG 可含脚本,默认禁止,避免 XSS + "image/x-icon": false, +} + +// allowedExts 扩展名白名单(与内容嗅探双重校验) +var allowedExts = map[string]bool{ + ".jpg": true, + ".jpeg": true, + ".png": true, + ".gif": true, + ".webp": true, + ".bmp": true, +} + // Upload 上传图片 func (h *ImageHandler) Upload(c *gin.Context) { file, err := c.FormFile("image") @@ -34,19 +58,10 @@ func (h *ImageHandler) Upload(c *gin.Context) { return } - // 验证文件类型 + // 验证扩展名 ext := strings.ToLower(filepath.Ext(file.Filename)) - allowedExts := map[string]bool{ - ".jpg": true, - ".jpeg": true, - ".png": true, - ".gif": true, - ".webp": true, - ".bmp": true, - } - if !allowedExts[ext] { - c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "不支持的图片格式,仅支持 jpg、png、gif、webp"}) + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "不支持的图片格式,仅支持 jpg、png、gif、webp、bmp"}) return } @@ -56,34 +71,60 @@ func (h *ImageHandler) Upload(c *gin.Context) { return } - // 生成唯一文件名 - filename := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), randomString(8), ext) - filepath := filepath.Join(h.uploadDir, filename) + // 打开文件做内容嗅探,防止伪造扩展名上传恶意内容 + src, err := file.Open() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "读取文件失败"}) + return + } + defer src.Close() + + head := make([]byte, 512) + if _, err := src.Read(head); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "读取文件失败"}) + return + } + + mime := mimetype.Detect(head) + if !allowedImageTypes[mime.String()] && mime.String() != "" { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "文件内容不是合法图片"}) + return + } + + // 生成唯一文件名(使用安全随机源) + filename := fmt.Sprintf("%d_%s%s", osTimeNano(), secureRandomString(8), ext) + targetPath := filepath.Join(h.uploadDir, filename) // 保存文件 - if err := c.SaveUploadedFile(file, filepath); err != nil { + if err := c.SaveUploadedFile(file, targetPath); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "保存图片失败"}) return } // 返回访问 URL - url := "/uploads/" + filename c.JSON(http.StatusOK, gin.H{ "code": 0, "message": "上传成功", "data": gin.H{ - "url": url, + "url": "/uploads/" + filename, + "mime": mime.String(), + "width": 0, + "height": 0, }, }) } -// randomString 生成随机字符串 -func randomString(length int) string { - const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - result := make([]byte, length) - for i := range result { - result[i] = chars[time.Now().UnixNano()%int64(len(chars))] - time.Sleep(time.Nanosecond) - } - return string(result) +// osTimeNano 返回当前纳秒时间戳 +func osTimeNano() int64 { + return time.Now().UnixNano() +} + +// secureRandomString 使用 crypto/rand 生成安全的随机十六进制字符串 +func secureRandomString(bytesLen int) string { + b := make([]byte, bytesLen) + if _, err := rand.Read(b); err != nil { + // 兜底(几乎不会发生) + return fmt.Sprintf("%d", time.Now().UnixNano()) + } + return hex.EncodeToString(b) } diff --git a/handler/note_handler.go b/handler/note_handler.go index 6a405b3..cf1cafb 100644 --- a/handler/note_handler.go +++ b/handler/note_handler.go @@ -1,11 +1,16 @@ package handler import ( + "archive/zip" + "encoding/json" + "fmt" "io" "net/http" "net/url" + "path/filepath" "strconv" "strings" + "time" "github.com/gin-gonic/gin" "note-manager/model" @@ -48,22 +53,8 @@ func fail(c *gin.Context, status int, msg string) { c.JSON(status, Response{Code: -1, Message: msg}) } -// requireAuth 需要管理员权限 -func requireAuth(c *gin.Context) bool { - token, err := c.Cookie("admin_token") - if err != nil || token != "authenticated" { - c.JSON(http.StatusUnauthorized, Response{Code: 401, Message: "请先登录后台管理"}) - return false - } - return true -} - // CreateNote 创建笔记 func (h *NoteHandler) CreateNote(c *gin.Context) { - if !requireAuth(c) { - return - } - var req model.NoteCreateRequest if err := c.ShouldBindJSON(&req); err != nil { fail(c, http.StatusBadRequest, "请求参数错误: "+err.Error()) @@ -79,7 +70,9 @@ func (h *NoteHandler) CreateNote(c *gin.Context) { success(c, note) } -// GetNote 获取笔记详情 +// GetNote 获取笔记详情(公开只读接口) +// 安全策略:仅允许返回「公开且无密码」的笔记。有密码或未公开的笔记一律返回需授权提示, +// 防止通过该接口绕过密码保护读取内容。 func (h *NoteHandler) GetNote(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { @@ -93,6 +86,29 @@ func (h *NoteHandler) GetNote(c *gin.Context) { return } + // 公开接口白名单:仅公开且无密码的笔记返回完整内容 + if !note.IsPublic || note.Password != "" { + fail(c, http.StatusForbidden, "该笔记受保护,无法直接访问") + return + } + + success(c, note) +} + +// GetAdminNote 获取笔记详情(管理后台用,返回完整内容) +func (h *NoteHandler) GetAdminNote(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + fail(c, http.StatusBadRequest, "无效的笔记 ID") + return + } + + note, err := h.svc.GetNote(uint(id)) + if err != nil { + fail(c, http.StatusNotFound, err.Error()) + return + } + success(c, note) } @@ -112,7 +128,7 @@ func (h *NoteHandler) AccessNote(c *gin.Context) { req.Password = c.Query("password") } - note, err := h.svc.GetNoteContent(uint(id), req.Password) + note, upgrade, err := h.svc.GetNoteContent(uint(id), req.Password) if err != nil { if err.Error() == "密码错误" { fail(c, http.StatusUnauthorized, err.Error()) @@ -122,15 +138,16 @@ func (h *NoteHandler) AccessNote(c *gin.Context) { return } + // 若旧 SHA-256 哈希命中,自动升级为 bcrypt + if upgrade { + _ = h.svc.UpgradePasswordHash(note.ID, req.Password) + } + success(c, note) } // UpdateNote 更新笔记 func (h *NoteHandler) UpdateNote(c *gin.Context) { - if !requireAuth(c) { - return - } - id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { fail(c, http.StatusBadRequest, "无效的笔记 ID") @@ -152,12 +169,8 @@ func (h *NoteHandler) UpdateNote(c *gin.Context) { success(c, note) } -// DeleteNote 删除笔记 +// DeleteNote 删除笔记(软删除,进入回收站) func (h *NoteHandler) DeleteNote(c *gin.Context) { - if !requireAuth(c) { - return - } - id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { fail(c, http.StatusBadRequest, "无效的笔记 ID") @@ -287,10 +300,168 @@ func parseIntDefault(s string, defaultVal int) int { return v } +// ─────────────── 回收站 ─────────────── + +// ListTrash 回收站列表 +func (h *NoteHandler) ListTrash(c *gin.Context) { + items, err := h.svc.ListTrash() + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + success(c, items) +} + +// RestoreNote 恢复笔记 +func (h *NoteHandler) RestoreNote(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + fail(c, http.StatusBadRequest, "无效的笔记 ID") + return + } + if err := h.svc.RestoreNote(uint(id)); err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + success(c, nil) +} + +// PurgeNote 彻底删除笔记 +func (h *NoteHandler) PurgeNote(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + fail(c, http.StatusBadRequest, "无效的笔记 ID") + return + } + if err := h.svc.PurgeNote(uint(id)); err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + success(c, nil) +} + +// EmptyTrash 清空回收站 +func (h *NoteHandler) EmptyTrash(c *gin.Context) { + if err := h.svc.EmptyTrash(); err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + success(c, nil) +} + +// ─────────────── 版本历史 ─────────────── + +// ListVersions 版本列表 +func (h *NoteHandler) ListVersions(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + fail(c, http.StatusBadRequest, "无效的笔记 ID") + return + } + versions, err := h.svc.ListVersions(uint(id)) + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + success(c, versions) +} + +// RestoreVersion 恢复到指定版本 +func (h *NoteHandler) RestoreVersion(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + fail(c, http.StatusBadRequest, "无效的笔记 ID") + return + } + versionID, err := strconv.ParseUint(c.PostForm("version_id"), 10, 64) + if err != nil { + fail(c, http.StatusBadRequest, "无效的版本 ID") + return + } + note, err := h.svc.RestoreVersion(uint(id), uint(versionID)) + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + success(c, note) +} + +// ─────────────── 分享 ─────────────── + +// CreateShare 创建分享 +func (h *NoteHandler) CreateShare(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + fail(c, http.StatusBadRequest, "无效的笔记 ID") + return + } + expireHours, _ := strconv.Atoi(c.DefaultPostForm("expire_hours", "0")) + note, err := h.svc.CreateShare(uint(id), expireHours) + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + success(c, gin.H{ + "id": note.ID, + "share_token": note.ShareToken, + "url": "/share/" + note.ShareToken, + "expire_at": note.ShareExpireAt, + }) +} + +// RevokeShare 撤销分享 +func (h *NoteHandler) RevokeShare(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + fail(c, http.StatusBadRequest, "无效的笔记 ID") + return + } + if err := h.svc.RevokeShare(uint(id)); err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + success(c, nil) +} + +// GetSharedNote 通过令牌获取分享笔记的 JSON 接口 +// 分享的笔记若设置了访问密码,则需通过 password 参数/请求体校验 +func (h *NoteHandler) GetSharedNote(c *gin.Context) { + token := c.Param("token") + var req struct { + Password string `json:"password"` + } + if err := c.ShouldBindJSON(&req); err != nil { + req.Password = c.Query("password") + } + + note, err := h.svc.GetSharedNote(token) + if err != nil { + fail(c, http.StatusNotFound, err.Error()) + return + } + + // 若分享的笔记有访问密码,校验 + if note.Password != "" { + ok, _ := model.CheckPassword(req.Password, note.Password) + if !ok { + fail(c, http.StatusUnauthorized, "密码错误") + return + } + } + + success(c, note) +} + +// SharePage 分享阅读页(简单 HTML,展示分享的笔记内容) +func (h *NoteHandler) SharePage(c *gin.Context) { + c.HTML(http.StatusOK, "share.html", gin.H{ + "token": c.Param("token"), + }) +} + // ExportNote 导出笔记为 Markdown 文件 func (h *NoteHandler) ExportNote(c *gin.Context) { - idStr := c.Param("id") - id, err := strconv.ParseUint(idStr, 10, 64) + id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { fail(c, http.StatusBadRequest, "无效的笔记 ID") return @@ -302,55 +473,103 @@ func (h *NoteHandler) ExportNote(c *gin.Context) { return } - // 如果是目录,导出目录下所有笔记 - if note.IsFolder { - fail(c, http.StatusBadRequest, "不支持导出目录,请选择具体笔记") + // 构造 Markdown(带 YAML front matter) + content := buildMarkdown(note) + + filename := sanitizeFileName(note.Title) + ".md" + c.Header("Content-Disposition", "attachment; filename=\""+filename+"\"") + c.Header("Content-Type", "text/markdown; charset=utf-8") + c.String(http.StatusOK, content) +} + +// ExportAll 批量导出全部笔记为 zip +func (h *NoteHandler) ExportAll(c *gin.Context) { + tree, err := h.svc.GetAllTree() + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) return } - // 设置下载头 - filename := note.Title + ".md" - c.Header("Content-Disposition", "attachment; filename*=UTF-8''"+urlEncode(filename)) - c.Header("Content-Type", "text/markdown; charset=utf-8") - - // 添加 front matter - frontMatter := "---\n" - frontMatter += "title: " + note.Title + "\n" - if note.Category != "" { - frontMatter += "category: " + note.Category + "\n" + // 构建节点 map 与根节点列表 + type node struct { + item model.NoteListItem + children []*node } - if note.Tags != "" { - frontMatter += "tags: " + note.Tags + "\n" + nodeMap := make(map[uint]*node) + for i := range tree { + nodeMap[tree[i].ID] = &node{item: tree[i]} + } + var roots []*node + for i := range tree { + n := nodeMap[tree[i].ID] + if tree[i].ParentID == 0 { + roots = append(roots, n) + } else if p, ok := nodeMap[tree[i].ParentID]; ok { + p.children = append(p.children, n) + } else { + roots = append(roots, n) + } } - frontMatter += "---\n\n" - c.String(http.StatusOK, frontMatter+note.Content) + c.Header("Content-Type", "application/zip") + c.Header("Content-Disposition", "attachment; filename=notes-export.zip") + zw := zip.NewWriter(c.Writer) + defer zw.Close() + + var zipNotes func(n *node, path string) error + zipNotes = func(n *node, path string) error { + if n.item.IsFolder { + dirPath := filepath.Join(path, sanitizeFileName(n.item.Title)) + for _, ch := range n.children { + if err := zipNotes(ch, dirPath); err != nil { + return err + } + } + return nil + } + note, err := h.svc.GetNote(n.item.ID) + if err != nil { + return err + } + body := buildMarkdown(note) + fname := filepath.Join(path, sanitizeFileName(note.Title)+".md") + fw, err := zw.Create(fname) + if err != nil { + return err + } + _, err = fw.Write([]byte(body)) + return err + } + + for _, root := range roots { + if err := zipNotes(root, ""); err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + } } -// ImportNotes 导入 Markdown 文件创建笔记 +// ImportNotes 导入 Markdown 文件 func (h *NoteHandler) ImportNotes(c *gin.Context) { file, err := c.FormFile("file") if err != nil { - fail(c, http.StatusBadRequest, "请选择文件") + fail(c, http.StatusBadRequest, "请选择要导入的文件") return } - // 验证文件类型 - if file.Header.Get("Content-Type") != "text/markdown" && - !strings.HasSuffix(file.Filename, ".md") { - fail(c, http.StatusBadRequest, "仅支持 .md 文件") + if !strings.HasSuffix(strings.ToLower(file.Filename), ".md") { + fail(c, http.StatusBadRequest, "仅支持导入 .md 文件") return } // 读取文件内容 - f, err := file.Open() + src, err := file.Open() if err != nil { fail(c, http.StatusInternalServerError, "读取文件失败") return } - defer f.Close() - - contentBytes, err := io.ReadAll(f) + defer src.Close() + contentBytes, err := io.ReadAll(src) if err != nil { fail(c, http.StatusInternalServerError, "读取文件失败") return @@ -406,3 +625,39 @@ func parseFrontMatter(content string) (title string, body string) { func urlEncode(s string) string { return url.QueryEscape(s) } + +// buildMarkdown 将笔记构造为带 YAML front matter 的 Markdown +func buildMarkdown(note *model.Note) string { + var b strings.Builder + b.WriteString("---\n") + b.WriteString("title: " + note.Title + "\n") + if note.Category != "" { + b.WriteString("category: " + note.Category + "\n") + } + // tags 是 JSON 数组字符串,转成 YAML 列表 + if note.Tags != "" { + var tags []string + if err := json.Unmarshal([]byte(note.Tags), &tags); err == nil && len(tags) > 0 { + b.WriteString("tags:\n") + for _, t := range tags { + b.WriteString(" - " + t + "\n") + } + } + } + b.WriteString("created: " + note.CreatedAt.Format("2006-01-02 15:04:05") + "\n") + b.WriteString("updated: " + note.UpdatedAt.Format("2006-01-02 15:04:05") + "\n") + b.WriteString("---\n\n") + b.WriteString(note.Content) + return b.String() +} + +// sanitizeFileName 清理文件名中的非法字符 +func sanitizeFileName(name string) string { + replacer := strings.NewReplacer("/", "_", "\\", "_", ":", "_", "*", "_", "?", "_", "\"", "_", "<", "_", ">", "_", "|", "_") + name = replacer.Replace(name) + name = strings.TrimSpace(name) + if name == "" { + return fmt.Sprintf("note-%d", time.Now().Unix()) + } + return name +} diff --git a/main.go b/main.go index 02f9d39..3f6f56a 100644 --- a/main.go +++ b/main.go @@ -52,7 +52,7 @@ func main() { log.Printf("前台展示: http://localhost%s/", addr) log.Printf("后台管理: http://localhost%s/admin/", addr) log.Printf("图片上传目录: %s", cfg.UploadDir) - log.Printf("默认密码: %s", cfg.AdminPass) + log.Printf("后台登录密码请通过 ADMIN_PASS 环境变量配置(生产环境务必修改默认密码)") if err := engine.Run(addr); err != nil { log.Fatalf("启动服务失败: %v", err) diff --git a/middleware/cors.go b/middleware/cors.go index 14081b8..340b9c9 100644 --- a/middleware/cors.go +++ b/middleware/cors.go @@ -1,20 +1,49 @@ package middleware -import "github.com/gin-gonic/gin" +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) // CORS 跨域中间件 +// 这是同源应用(前端、后台、API 都在同一服务),因此默认不对外部跨域放行。 +// 仅允许本站自身的 Origin 访问,避免任意外部站点的跨域请求读取或携带凭证。 func CORS() gin.HandlerFunc { return func(c *gin.Context) { - c.Header("Access-Control-Allow-Origin", "*") - c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") - c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization") - c.Header("Access-Control-Max-Age", "86400") + origin := c.GetHeader("Origin") + host := c.Request.Host - if c.Request.Method == "OPTIONS" { - c.AbortWithStatus(204) + // 同源请求或没有 Origin 的请求(curl 等)直接放行 + 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 } - 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) } } diff --git a/middleware/session.go b/middleware/session.go new file mode 100644 index 0000000..bc70926 --- /dev/null +++ b/middleware/session.go @@ -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 +} diff --git a/model/note.go b/model/note.go index c5f4a45..fd191fe 100644 --- a/model/note.go +++ b/model/note.go @@ -4,63 +4,96 @@ import ( "crypto/sha256" "encoding/hex" "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 { if password == "" { return "" } - hash := sha256.Sum256([]byte(password)) - return hex.EncodeToString(hash[:]) + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost) + if err != nil { + // 极端情况下 fallback 到 sha256 + s := sha256.Sum256([]byte(password)) + return hex.EncodeToString(s[:]) + } + return string(hash) } -// CheckPassword 验证密码 -func CheckPassword(password, hash string) bool { - // 如果笔记没有设置密码(hash 为空),则不需要验证 +// IsBcryptHash 判断哈希是否为 bcrypt 格式 +func IsBcryptHash(hash string) bool { + 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 == "" { - return true + return true, false } - // 如果用户没有输入密码,验证失败 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 笔记模型(也用于目录) type Note struct { - ID uint `json:"id" gorm:"primaryKey"` - Title string `json:"title" gorm:"size:255;not null" binding:"required"` - Content string `json:"content" gorm:"type:text"` - Category string `json:"category" gorm:"size:100;index"` - Tags string `json:"tags" gorm:"type:text"` // JSON 数组格式存储 - Password string `json:"-" gorm:"size:255"` // 访问密码(哈希存储) - IsPinned bool `json:"is_pinned" gorm:"default:false"` - IsFavorite bool `json:"is_favorite" gorm:"default:false"` - IsPublic bool `json:"is_public"` // 是否公开 - ParentID uint `json:"parent_id" gorm:"default:0;index"` // 父级目录 ID,0 表示根目录 - IsFolder bool `json:"is_folder" gorm:"default:false"` // 是否为文件夹 - SortOrder int `json:"sort_order" gorm:"default:0"` // 排序顺序 - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `json:"id" gorm:"primaryKey"` + Title string `json:"title" gorm:"size:255;not null"` + Content string `json:"content" gorm:"type:text"` + Category string `json:"category" gorm:"size:100;index"` + Tags string `json:"tags" gorm:"type:text"` // JSON 数组格式存储 + Password string `json:"-" gorm:"size:255"` // 访问密码(哈希存储) + IsPinned bool `json:"is_pinned" gorm:"default:false"` + IsFavorite bool `json:"is_favorite" gorm:"default:false"` + IsPublic bool `json:"is_public"` // 是否公开 + ParentID uint `json:"parent_id" gorm:"default:0;index"` // 父级目录 ID,0 表示根目录 + IsFolder bool `json:"is_folder" gorm:"default:false"` // 是否为文件夹 + SortOrder int `json:"sort_order" gorm:"default:0"` // 排序顺序 + ShareToken string `json:"-" gorm:"size:64;index"` // 分享令牌,空 = 未分享 + 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 笔记列表响应(不含内容和密码) type NoteListItem struct { - ID uint `json:"id"` - Title string `json:"title"` - Category string `json:"category"` - Tags string `json:"tags"` - HasPassword bool `json:"has_password"` // 是否有密码保护 - IsPinned bool `json:"is_pinned"` - IsFavorite bool `json:"is_favorite"` - IsPublic bool `json:"is_public"` - ParentID uint `json:"parent_id"` - IsFolder bool `json:"is_folder"` - SortOrder int `json:"sort_order"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `json:"id"` + Title string `json:"title"` + Category string `json:"category"` + Tags string `json:"tags"` + HasPassword bool `json:"has_password"` // 是否有密码保护 + IsPinned bool `json:"is_pinned"` + IsFavorite bool `json:"is_favorite"` + IsPublic bool `json:"is_public"` + ParentID uint `json:"parent_id"` + IsFolder bool `json:"is_folder"` + SortOrder int `json:"sort_order"` + ShareToken string `json:"share_token,omitempty"` // 分享令牌(后台完整列表需要) + 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 创建笔记请求 @@ -80,21 +113,37 @@ type NoteCreateRequest struct { // NoteUpdateRequest 更新笔记请求 type NoteUpdateRequest struct { - Title *string `json:"title"` - Content *string `json:"content"` - Category *string `json:"category"` - Tags *string `json:"tags"` - Password *string `json:"password"` - RemovePassword *bool `json:"remove_password"` // 是否移除密码 - IsPinned *bool `json:"is_pinned"` - IsFavorite *bool `json:"is_favorite"` - IsPublic *bool `json:"is_public"` - ParentID *uint `json:"parent_id"` - IsFolder *bool `json:"is_folder"` - SortOrder *int `json:"sort_order"` + Title *string `json:"title"` + Content *string `json:"content"` + Category *string `json:"category"` + Tags *string `json:"tags"` + Password *string `json:"password"` + RemovePassword *bool `json:"remove_password"` // 是否移除密码 + IsPinned *bool `json:"is_pinned"` + IsFavorite *bool `json:"is_favorite"` + IsPublic *bool `json:"is_public"` + ParentID *uint `json:"parent_id"` + IsFolder *bool `json:"is_folder"` + SortOrder *int `json:"sort_order"` } // NoteAccessRequest 笔记访问请求(验证密码) type NoteAccessRequest struct { 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" +} diff --git a/repository/note_repository.go b/repository/note_repository.go index 9105c33..e8b246c 100644 --- a/repository/note_repository.go +++ b/repository/note_repository.go @@ -1,6 +1,7 @@ package repository import ( + "errors" "fmt" "os" "path/filepath" @@ -28,8 +29,11 @@ func NewNoteRepository(dbPath string) (*NoteRepository, error) { 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) } @@ -38,10 +42,11 @@ func NewNoteRepository(dbPath string) (*NoteRepository, error) { // Create 创建笔记 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) { var note model.Note err := r.db.First(¬e, id).Error @@ -51,12 +56,27 @@ func (r *NoteRepository) GetByID(id uint) (*model.Note, error) { 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 更新笔记 func (r *NoteRepository) Update(note *model.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 { 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 - 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"). Offset(offset). Limit(q.PageSize). @@ -109,17 +129,17 @@ func (r *NoteRepository) List(q ListQuery) ([]model.NoteListItem, int64, error) return items, total, err } -// GetAllTree 获取所有笔记的树形结构 +// GetAllTree 获取所有笔记的树形结构(含分享信息) func (r *NoteRepository) GetAllTree() ([]model.NoteListItem, error) { var items []model.NoteListItem 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"). Find(&items).Error return items, err } -// GetPublicTree 获取公开可见的树形结构(显示所有目录和笔记,访问时再验证密码) +// GetPublicTree 获取公开可见的树形结构(前台用,不含分享令牌) func (r *NoteRepository) GetPublicTree() ([]model.NoteListItem, error) { var items []model.NoteListItem err := r.db.Model(&model.Note{}). @@ -129,7 +149,7 @@ func (r *NoteRepository) GetPublicTree() ([]model.NoteListItem, error) { return items, err } -// GetByParentID 获取指定父目录下的所有项目 +// GetByParentID 获取指定父目录下的所有项目(排除已删除) func (r *NoteRepository) GetByParentID(parentID uint) ([]model.NoteListItem, error) { var items []model.NoteListItem err := r.db.Model(&model.Note{}). @@ -140,21 +160,121 @@ func (r *NoteRepository) GetByParentID(parentID uint) ([]model.NoteListItem, err return items, err } -// GetChildrenCount 获取子项数量 +// GetChildrenCount 获取子项数量(排除已删除) func (r *NoteRepository) GetChildrenCount(parentID uint) (int64, error) { var count int64 err := r.db.Model(&model.Note{}).Where("parent_id = ?", parentID).Count(&count).Error return count, err } -// DeleteWithChildren 删除目录及其下所有内容 +// DeleteWithChildren 软删除目录及其下所有内容 func (r *NoteRepository) DeleteWithChildren(id uint) error { - // 先删除所有子项 - if err := r.db.Where("parent_id = ?", id).Delete(&model.Note{}).Error; err != nil { + // 递归收集所有后代 id + 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 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 搜索笔记(按标题和内容) @@ -170,7 +290,7 @@ func (r *NoteRepository) Search(keyword string, page, pageSize int) ([]model.Not } 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"). Offset(offset). Limit(pageSize). @@ -179,7 +299,7 @@ func (r *NoteRepository) Search(keyword string, page, pageSize int) ([]model.Not return items, total, err } -// GetCategories 获取所有分类 +// GetCategories 获取所有分类(排除已删除和目录) func (r *NoteRepository) GetCategories() ([]string, error) { var categories []string err := r.db.Model(&model.Note{}). @@ -189,7 +309,7 @@ func (r *NoteRepository) GetCategories() ([]string, error) { return categories, err } -// GetTags 获取所有标签 +// GetTags 获取所有标签(排除已删除和目录) func (r *NoteRepository) GetTags() ([]string, error) { var tagsJSON []string err := r.db.Model(&model.Note{}). @@ -210,3 +330,69 @@ func (r *NoteRepository) GetTags() ([]string, error) { } 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 +} diff --git a/router/router.go b/router/router.go index 4fd617b..ae82dc8 100644 --- a/router/router.go +++ b/router/router.go @@ -15,47 +15,60 @@ func Setup(r *gin.Engine, noteHandler *handler.NoteHandler, adminHandler *handle // 静态文件服务(图片) r.Static("/uploads", cfg.UploadDir) - // API 路由组 + // ─────────── 公开 API ─────────── api := r.Group("/api") { notes := api.Group("/notes") { - // 公开只读接口 notes.GET("", noteHandler.ListNotes) notes.GET("/search", noteHandler.SearchNotes) - notes.GET("/:id", noteHandler.GetNote) + notes.GET("/:id", noteHandler.GetNote) // 公开安全版:仅公开且无密码的笔记 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("/tags", noteHandler.GetTags) api.GET("/tree", noteHandler.GetPublicTree) // 前台公开树 + + // 分享 JSON 接口(公开) + api.GET("/share/:token", noteHandler.GetSharedNote) } - // 管理后台专用 API(需要认证) + // ─────────── 管理后台 API(需认证)─────────── adminApi := r.Group("/admin/api") - adminApi.Use(func(c *gin.Context) { - 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.Use(middleware.AuthRequired()) { - adminApi.GET("/tree", noteHandler.GetTree) // 后台完整树 - adminApi.POST("/upload", imageHandler.Upload) // 图片上传 - adminApi.GET("/export/:id", noteHandler.ExportNote) // 导出笔记 - adminApi.POST("/import", noteHandler.ImportNotes) // 导入笔记 + // 笔记写操作 + adminApi.POST("/notes", noteHandler.CreateNote) + adminApi.GET("/notes/:id", noteHandler.GetAdminNote) + 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.GET("/login", adminHandler.LoginPage) @@ -65,6 +78,9 @@ func Setup(r *gin.Engine, noteHandler *handler.NoteHandler, adminHandler *handle admin.GET("/", adminHandler.IndexPage) } + // ─────────── 分享阅读页 ─────────── + r.GET("/share/:token", noteHandler.SharePage) + // 健康检查 r.GET("/health", func(c *gin.Context) { c.JSON(200, gin.H{"status": "ok"}) diff --git a/service/note_service.go b/service/note_service.go index 0aedf16..9107047 100644 --- a/service/note_service.go +++ b/service/note_service.go @@ -1,10 +1,14 @@ package service import ( + "crypto/rand" + "encoding/hex" "errors" "fmt" "strconv" + "time" + "gorm.io/gorm" "note-manager/model" "note-manager/repository" ) @@ -23,13 +27,13 @@ func NewNoteService(repo *repository.NoteRepository, pageSize int) *NoteService // CreateNote 创建笔记或目录 func (s *NoteService) CreateNote(req model.NoteCreateRequest) (*model.Note, error) { note := &model.Note{ - Title: req.Title, - Content: req.Content, - Category: req.Category, - Tags: req.Tags, - IsFolder: req.IsFolder, + Title: req.Title, + Content: req.Content, + Category: req.Category, + Tags: req.Tags, + IsFolder: req.IsFolder, SortOrder: req.SortOrder, - IsPublic: true, // 默认公开 + IsPublic: true, // 默认公开 } if req.Password != "" { note.Password = model.HashPassword(req.Password) @@ -50,40 +54,52 @@ func (s *NoteService) CreateNote(req model.NoteCreateRequest) (*model.Note, erro if err := s.repo.Create(note); err != nil { return nil, fmt.Errorf("创建笔记失败: %w", err) } + // 创建时保存第一版历史 + _, _ = s.repo.SaveVersion(note) return note, nil } -// GetNote 获取单条笔记 +// GetNote 获取单条笔记(后台管理使用,任意笔记) func (s *NoteService) GetNote(id uint) (*model.Note, error) { note, err := s.repo.GetByID(id) if err != nil { - return nil, errors.New("笔记不存在") + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("笔记不存在") + } + return nil, err } return note, nil } -// GetNoteContent 获取笔记内容(需要密码验证) -func (s *NoteService) GetNoteContent(id uint, password string) (*model.Note, error) { +// GetNoteContent 获取笔记内容(需密码验证,用于前台展示) +// 返回 (note, 是否需要升级密码哈希, err) +func (s *NoteService) GetNoteContent(id uint, password string) (*model.Note, bool, error) { note, err := s.repo.GetByID(id) if err != nil { - return nil, errors.New("笔记不存在") + return nil, false, errors.New("笔记不存在") } // 检查密码 if note.Password != "" { - if !model.CheckPassword(password, note.Password) { - return nil, errors.New("密码错误") + ok, upgrade := model.CheckPassword(password, note.Password) + if !ok { + return nil, false, errors.New("密码错误") } + return note, upgrade, nil } - return note, nil + return note, false, nil } -// UpdateNote 更新笔记或目录 +// UpdateNote 更新笔记或目录(保存更新前快照到版本历史) func (s *NoteService) UpdateNote(id uint, req model.NoteUpdateRequest) (*model.Note, error) { note, err := s.repo.GetByID(id) if err != nil { return nil, errors.New("笔记不存在") } + // 记录旧状态,判断是否产生实质内容变化 + oldContent := note.Content + oldTitle := note.Title + if req.Title != nil { note.Title = *req.Title } @@ -116,17 +132,22 @@ func (s *NoteService) UpdateNote(id uint, req model.NoteUpdateRequest) (*model.N } if req.RemovePassword != nil && *req.RemovePassword { note.Password = "" - } else if req.Password != nil { + } else if req.Password != nil && *req.Password != "" { note.Password = model.HashPassword(*req.Password) } if err := s.repo.Update(note); err != nil { return nil, fmt.Errorf("更新笔记失败: %w", err) } + + // 若内容或标题发生变化,保存历史版本 + if oldContent != note.Content || oldTitle != note.Title { + _, _ = s.repo.SaveVersion(note) + } return note, nil } -// DeleteNote 删除笔记或目录(目录会删除所有子项) +// DeleteNote 软删除笔记或目录(目录会软删除所有子项) func (s *NoteService) DeleteNote(id uint) error { note, err := s.repo.GetByID(id) if err != nil { @@ -217,6 +238,158 @@ func (s *NoteService) GetTags() ([]string, error) { return s.repo.GetTags() } +// ─────────────── 回收站 ─────────────── + +// ListTrash 获取回收站列表 +func (s *NoteService) ListTrash() ([]model.NoteListItem, error) { + return s.repo.ListTrash() +} + +// RestoreNote 从回收站恢复笔记或目录(整棵子树) +func (s *NoteService) RestoreNote(id uint) error { + note, err := s.repo.GetByIDIncludingDeleted(id) + if err != nil { + return errors.New("记录不存在") + } + if note.IsFolder { + return s.repo.RestoreSubtree(id) + } + return s.repo.Restore(id) +} + +// PurgeNote 彻底删除笔记或目录(不可恢复) +func (s *NoteService) PurgeNote(id uint) error { + note, err := s.repo.GetByIDIncludingDeleted(id) + if err != nil { + return errors.New("记录不存在") + } + if note.IsFolder { + if err := s.repo.HardDeleteWithChildren(id); err != nil { + return err + } + } else { + if err := s.repo.HardDelete(id); err != nil { + return err + } + } + // 清理版本历史 + _ = s.repo.DeleteVersions(id) + return nil +} + +// EmptyTrash 清空回收站 +func (s *NoteService) EmptyTrash() error { + trash, err := s.repo.ListTrash() + if err != nil { + return err + } + for _, item := range trash { + if err := s.PurgeNote(item.ID); err != nil { + return err + } + } + return nil +} + +// ─────────────── 版本历史 ─────────────── + +// ListVersions 获取笔记版本列表 +func (s *NoteService) ListVersions(noteID uint) ([]model.NoteVersion, error) { + return s.repo.ListVersions(noteID) +} + +// RestoreVersion 将笔记恢复到指定版本 +func (s *NoteService) RestoreVersion(noteID, versionID uint) (*model.Note, error) { + version, err := s.repo.GetVersion(versionID) + if err != nil { + return nil, errors.New("版本不存在") + } + note, err := s.repo.GetByID(noteID) + if err != nil { + return nil, errors.New("笔记不存在") + } + // 保存当前状态为历史版本(防止覆盖) + _, _ = s.repo.SaveVersion(note) + // 回滚 + note.Title = version.Title + note.Content = version.Content + note.Category = version.Category + note.Tags = version.Tags + if err := s.repo.Update(note); err != nil { + return nil, fmt.Errorf("恢复版本失败: %w", err) + } + return note, nil +} + +// ─────────────── 分享 ─────────────── + +// CreateShare 创建/更新分享令牌 +func (s *NoteService) CreateShare(noteID uint, expireHours int) (*model.Note, error) { + note, err := s.repo.GetByID(noteID) + if err != nil { + return nil, errors.New("笔记不存在") + } + if note.IsFolder { + return nil, errors.New("目录不能分享") + } + note.ShareToken = randomToken(32) + if expireHours > 0 { + t := time.Now().Add(time.Duration(expireHours) * time.Hour) + note.ShareExpireAt = &t + } else { + note.ShareExpireAt = nil + } + if err := s.repo.Update(note); err != nil { + return nil, fmt.Errorf("创建分享失败: %w", err) + } + return note, nil +} + +// RevokeShare 撤销分享 +func (s *NoteService) RevokeShare(noteID uint) error { + note, err := s.repo.GetByID(noteID) + if err != nil { + return errors.New("笔记不存在") + } + note.ShareToken = "" + note.ShareExpireAt = nil + return s.repo.Update(note) +} + +// GetSharedNote 通过令牌获取分享笔记(校验过期时间) +func (s *NoteService) GetSharedNote(token string) (*model.Note, error) { + note, err := s.repo.GetByShareToken(token) + if err != nil { + return nil, err + } + if note.ShareExpireAt != nil && time.Now().After(*note.ShareExpireAt) { + return nil, errors.New("分享链接已过期") + } + // 若有密码则需校验(在 handler 层处理) + _ = s.repo.IncrementVisitByToken(token) + return note, nil +} + +// UpgradePasswordHash 将旧 SHA-256 密码哈希升级为 bcrypt +func (s *NoteService) UpgradePasswordHash(id uint, password string) error { + note, err := s.repo.GetByID(id) + if err != nil { + return err + } + note.Password = model.HashPassword(password) + return s.repo.Update(note) +} + +// randomToken 生成安全的随机令牌 +func randomToken(bytesLen int) string { + b := make([]byte, bytesLen) + if _, err := rand.Read(b); err != nil { + // 兜底使用时间戳(几乎不会发生) + return fmt.Sprintf("%d", time.Now().UnixNano()) + } + return hex.EncodeToString(b) +} + func parseInt(s string, defaultVal int) int { if s == "" { return defaultVal diff --git a/smoke_test.py b/smoke_test.py new file mode 100644 index 0000000..2a4fc7f --- /dev/null +++ b/smoke_test.py @@ -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"alert(1)" +# 用 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) diff --git a/web/admin/index.html b/web/admin/index.html index 445efac..5b317f0 100644 --- a/web/admin/index.html +++ b/web/admin/index.html @@ -317,12 +317,32 @@ transition: background 0.2s; } .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; }

笔记管理后台

+ + + 前台预览
@@ -364,6 +384,8 @@ + + @@ -418,7 +440,7 @@