feat: 云笔记增强 - 安全加固 + 回收站/版本历史/分享/批量导出 + 前端优化

- 安全: 认证改随机token会话(弃固定cookie), 笔记密码SHA256升级为bcrypt(自动迁移),
  堵住GET /api/notes/:id泄露带密码笔记, CORS收紧+SameSite防CSRF, 上传图片内容嗅探
- 回收站: 软删除(deleted_at), 列表/恢复/彻底删除/清空, 目录子树连删连恢复
- 版本历史: note_versions表存快照, 每次保存自动留档, 支持查看/回滚
- 分享: 生成随机token分享链接, 支持过期时间, 公开阅读页share.html
- 批量导出: 全部笔记打包zip(按目录结构+front matter)
- 前端: 深色模式, Mermaid图表, 待办清单checkbox, 字数统计;
  后台新增回收站/历史/分享面板和批量导出按钮
- 新增deploy/note-manager.service systemd单元与smoke_test.py
This commit is contained in:
Your Name
2026-08-11 11:21:29 +08:00
parent f0eb822e68
commit 74fa759274
18 changed files with 1968 additions and 228 deletions
+37 -8
View File
@@ -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)
}
}
+109
View File
@@ -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 设置认证 cookieSameSite=Lax 防 CSRFhttps 下应启用 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
}