HTTP 层与单二进制入口:路由 handler、会话/CSRF/限流防线、SPA 嵌入与 meta 注入

- internal/httpapi:§7.1 全部路由(公开浏览 / 管理端 / 认证 / feed),
  服务端统一可见性过滤(含回收站仅 admin 出口)、图片魔数校验与
  immutable/no-store 缓存头分流、统一 404 防枚举、slug 自解冲突与
  409 字段级错误、fail-only 登录限流(429 + Retry-After)、
  设置白名单(永不序列化口令哈希)
- internal/webui:go:embed dist + SPA fallback(资产指纹长缓存、
  深链回退 index.html)+ html/template 元信息注入(仅可见笔记)
- cmd/pure-note:serve/init/backup/gc/version 子命令,优雅停机与
  每小时会话清理
- 含全部 §13 测试组:表驱动可见性矩阵、迁移守卫、认证会话、CSRF、
  上传、回收站/gc、slug 策略、设置白名单、webui MapFS 单测
This commit is contained in:
2026-09-08 08:14:23 +08:00
parent 247e88c4fb
commit 6e83426ca9
12 changed files with 2930 additions and 0 deletions
+92
View File
@@ -0,0 +1,92 @@
package httpapi
import (
"errors"
"net/http"
"strconv"
"time"
"pure-note/internal/auth"
"pure-note/internal/middleware"
)
type loginRequest struct {
Password string `json:"password"`
}
// handleLogin POST /api/auth/login。
// Origin 校验由全局中间件完成(含 login,§9.1-T2);此处做防爆破与口令校验。
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "请求体不是合法 JSON")
return
}
ip := middleware.ClientIP(r, s.cfg.BehindProxy)
const account = "admin" // 单管理员账号维度
// 预检:桶已耗尽直接 429(避免无谓的 Argon2 计算),429 + Retry-After(§7.2)
if !s.loginIP.Available(ip) || !s.loginAcct.Available(account) {
retry := max(s.loginIP.RetryAfter(ip), s.loginAcct.RetryAfter(account))
w.Header().Set("Retry-After", strconv.Itoa(retry))
writeError(w, http.StatusTooManyRequests, "rate_limited", "尝试过于频繁,请稍后再试")
return
}
hash, ok, err := s.st.GetSetting("admin_password_hash")
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
return
}
if !ok {
writeError(w, http.StatusInternalServerError, "not_initialized", "尚未初始化管理员口令,请先执行 pure-note init")
return
}
if req.Password == "" || !auth.VerifyPassword(hash, req.Password) {
// 失败才计费:消费两维度令牌(fail-only,§7.3-2)
s.loginIP.Allow(ip)
s.loginAcct.Allow(account)
// 记录 IP 与桶剩余计数(§7.2/§10.5)
s.log.Warn("login_failed",
"ip", ip,
"ip_bucket_left", s.loginIP.Remaining(ip),
"account_bucket_left", s.loginAcct.Remaining(account))
// 统一 401 文案,不泄露差异(§7.3-2)
writeError(w, http.StatusUnauthorized, "invalid_credentials", "用户名或密码错误")
return
}
// 登录成功:重建会话行(防会话固定,§7.3-3)
token, err := newToken()
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
return
}
csrf, err := newToken()
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
return
}
now := time.Now().Unix()
if err := s.st.CreateSession(hashToken(token), csrf, now, now+int64(sessionTTL.Seconds())); err != nil {
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
return
}
http.SetCookie(w, s.sessionCookie(token, int(sessionTTL.Seconds())))
s.log.Info("admin_action", "op", "login", "ip", ip)
writeJSON(w, http.StatusOK, map[string]string{"csrf_token": csrf})
}
// handleLogout POST /api/auth/logout:删除会话行 + 清 Cookie。
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(s.cookieName()); err == nil && c.Value != "" {
if err := s.st.DeleteSession(hashToken(c.Value)); err != nil && !errors.Is(err, nil) {
// 删除失败不阻断登出(幂等)
s.log.Error("删除会话失败", "err", err)
}
}
http.SetCookie(w, s.sessionCookie("", -1))
s.log.Info("admin_action", "op", "logout")
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}