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
+356
View File
@@ -0,0 +1,356 @@
// Package httpapi 路由注册与全部 HTTP handler。
package httpapi
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"strconv"
"time"
"pure-note/internal/config"
"pure-note/internal/middleware"
"pure-note/internal/store"
"pure-note/internal/webui"
)
// 会话与安全参数(§7.3)。
const (
sessionTTL = 7 * 24 * time.Hour // 会话有效期
rotateThreshold = 3 * 24 * time.Hour // 距过期 < 3 天时滑动轮换
cookieName = "__Host-pn_session" // 生产 Cookie(__Host- 前缀)
devCookieName = "pn_session" // 开发模式 Cookie(非 Secure)
maxAuthBody = 64 << 10 // auth/settings 请求体上限 64KB
maxNoteBody = 1 << 20 // notes JSON 上限 1MB
maxUploadBody = 6 << 20 // multipart 上限 6MB(含边界开销)
maxImageSize = 5 << 20 // 单图上限 5MB
)
// Server HTTP 服务。
type Server struct {
st *store.Store
cfg *config.Config
log *slog.Logger
global *middleware.Limiter // 全局宽松限流(per-IP)
loginIP *middleware.Limiter // 登录/改密 per-IP:10 次/5 分钟
loginAcct *middleware.Limiter // 登录/改密 per-账号:5 次/10 分钟
}
// New 构造 Server(生产限流参数,§7.2)。
func New(st *store.Store, cfg *config.Config, log *slog.Logger) *Server {
return NewWithLimiters(st, cfg, log,
// 全局宽松令牌桶:20 req/s、burst 40;桶上限 4096 + 10 分钟 TTL 逐出
middleware.NewLimiter(20, 40, 4096, 10*time.Minute),
// 登录/改密:per-IP 10 次/5 分钟 + per-账号 5 次/10 分钟(fail-only)
middleware.NewLimiter(10.0/(5*60), 10, 4096, 10*time.Minute),
middleware.NewLimiter(5.0/(10*60), 5, 16, 10*time.Minute),
)
}
// NewWithLimiters 测试注入口:替换各维度限流器。
func NewWithLimiters(st *store.Store, cfg *config.Config, log *slog.Logger,
global, loginIP, loginAcct *middleware.Limiter) *Server {
return &Server{
st: st,
cfg: cfg,
log: log,
global: global,
loginIP: loginIP,
loginAcct: loginAcct,
}
}
// Handler 组装完整路由与中间件链(§7.2 顺序)。
func (s *Server) Handler(ui http.Handler) http.Handler {
mux := http.NewServeMux()
// ---- 公共 API ----
mux.HandleFunc("GET /api/health", s.handleHealth)
mux.HandleFunc("GET /api/me", s.handleMe)
mux.HandleFunc("GET /api/site", s.handleSiteInfo)
mux.HandleFunc("GET /api/notes", s.handlePublicNotes)
mux.HandleFunc("GET /api/notes/{slug}", s.handlePublicNote)
mux.HandleFunc("GET /api/tags", s.handleTags)
mux.HandleFunc("GET /api/images/{id}", s.handleImage)
// ---- 订阅与 SEO ----
mux.HandleFunc("GET /feed.xml", s.handleRSS)
mux.HandleFunc("GET /sitemap.xml", s.handleSitemap)
mux.HandleFunc("GET /robots.txt", s.handleRobots)
// ---- 认证(Origin 校验全量覆盖非 GET,含 /api/auth/*)----
authMux := http.NewServeMux()
authMux.Handle("POST /api/auth/login", middleware.MaxBytes(maxAuthBody)(http.HandlerFunc(s.handleLogin)))
authMux.Handle("POST /api/auth/logout", middleware.MaxBytes(maxAuthBody)(http.HandlerFunc(s.handleLogout)))
mux.Handle("/api/auth/", middleware.NoStore(authMux))
// ---- 管理(会话 + CSRF)----
adminMux := http.NewServeMux()
adminMux.HandleFunc("GET /api/admin/notes", s.handleAdminNotes)
adminMux.Handle("POST /api/admin/notes", middleware.MaxBytes(maxNoteBody)(http.HandlerFunc(s.handleAdminNoteCreate)))
adminMux.HandleFunc("GET /api/admin/notes/{id}", s.handleAdminNoteGet)
adminMux.Handle("PUT /api/admin/notes/{id}", middleware.MaxBytes(maxNoteBody)(http.HandlerFunc(s.handleAdminNoteUpdate)))
adminMux.HandleFunc("DELETE /api/admin/notes/{id}", s.handleAdminNoteDelete)
adminMux.HandleFunc("GET /api/admin/trash", s.handleAdminTrash)
adminMux.HandleFunc("POST /api/admin/trash/{id}/restore", s.handleAdminTrashRestore)
adminMux.Handle("POST /api/admin/images", middleware.MaxBytes(maxUploadBody)(http.HandlerFunc(s.handleAdminImageUpload)))
adminMux.HandleFunc("GET /api/admin/images", s.handleAdminImages)
adminMux.HandleFunc("GET /api/admin/settings", s.handleAdminSettingsGet)
adminMux.Handle("PUT /api/admin/settings", middleware.MaxBytes(maxAuthBody)(http.HandlerFunc(s.handleAdminSettingsPut)))
adminMux.Handle("POST /api/admin/password", middleware.MaxBytes(maxAuthBody)(http.HandlerFunc(s.handleAdminPassword)))
mux.Handle("/api/admin/", s.requireAdmin(adminMux))
// ---- SPA(兜底,须最后注册)----
if ui != nil {
mux.Handle("/", ui)
}
// 中间件链:SecurityHeaders → 日志 → 全局限流 → Origin 校验
var h http.Handler = mux
h = middleware.OriginCheck(h)
h = s.globalLimit(h)
h = middleware.RequestLogger(s.log)(h)
h = middleware.SecurityHeaders(h)
h = middleware.BehindProxy(s.cfg.BehindProxy)(h)
return h
}
// globalLimit 全局宽松限流(per-IP),超限 429。
func (s *Server) globalLimit(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := middleware.ClientIP(r, s.cfg.BehindProxy)
if !s.global.Allow(ip) {
writeError(w, http.StatusTooManyRequests, "rate_limited", "请求过于频繁")
return
}
next.ServeHTTP(w, r)
})
}
// requireAdmin 会话校验(401)→ CSRF 头校验(403,仅变更请求)。
func (s *Server) requireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sess, ok := s.sessionFrom(r)
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized", "未登录或会话已过期")
return
}
if r.Method != http.MethodGet && r.Method != http.MethodHead {
token := r.Header.Get("X-CSRF-Token")
if token == "" || token != sess.CSRFToken {
writeError(w, http.StatusForbidden, "csrf_failed", "CSRF 校验失败")
return
}
}
// 滑动续期:距过期 < 3 天时重建会话行并轮换 Cookie(csrf 不变)
s.maybeRotate(w, sess)
next.ServeHTTP(w, r)
})
}
// ---- JSON 包络(§7.1 统一约定)----
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]any{"data": v})
}
// apiError 业务错误(field 非空时为字段级错误提示,§8.2 slug 冲突)。
type apiError struct {
Code string `json:"code"`
Message string `json:"message"`
Field string `json:"field,omitempty"`
}
func writeError(w http.ResponseWriter, status int, code, msg string) {
writeErrorFull(w, status, apiError{Code: code, Message: msg})
}
func writeErrorFull(w http.ResponseWriter, status int, e apiError) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]any{"error": e})
}
// ---- 请求/响应小工具 ----
func decodeJSON(r *http.Request, v any) error {
dec := json.NewDecoder(r.Body)
return dec.Decode(v)
}
func pathID(r *http.Request) (int64, bool) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || id <= 0 {
return 0, false
}
return id, true
}
// parsePagination 解析 page/page_size,越界返回 ok=false(§7.1:400)。
func (s *Server) parsePagination(r *http.Request) (page, pageSize int, ok bool) {
page = 1
pageSize = s.defaultPageSize()
if v := r.URL.Query().Get("page"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n < 1 || n > 10000 {
return 0, 0, false
}
page = n
}
if v := r.URL.Query().Get("page_size"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n < 1 || n > 100 {
return 0, 0, false
}
pageSize = n
}
return page, pageSize, true
}
func (s *Server) defaultPageSize() int {
ss, err := s.st.GetSiteSettings()
if err != nil {
return store.DefaultPageSize
}
return ss.PageSize
}
// ---- 会话 ----
// cookieName 按模式返回。
func (s *Server) cookieName() string {
if s.cfg.Dev {
return devCookieName
}
return cookieName
}
// sessionCookie 构造会话 Cookie(§7.3-3:__Host- 前缀 + Secure + HttpOnly + Lax + Path=/ + 7d)。
func (s *Server) sessionCookie(value string, maxAge int) *http.Cookie {
return &http.Cookie{
Name: s.cookieName(),
Value: value,
Path: "/",
MaxAge: maxAge,
HttpOnly: true,
Secure: !s.cfg.Dev,
SameSite: http.SameSiteLaxMode,
}
}
// sessionFrom 从 Cookie 还原有效会话(过期行视作无会话)。
func (s *Server) sessionFrom(r *http.Request) (*store.Session, bool) {
c, err := r.Cookie(s.cookieName())
if err != nil || c.Value == "" {
return nil, false
}
sess, err := s.st.GetSession(hashToken(c.Value))
if err != nil {
if errors.Is(err, store.ErrNotFound) {
return nil, false
}
s.log.Error("查询会话失败", "err", err)
return nil, false
}
if sess.ExpiresAt <= time.Now().Unix() {
_ = s.st.DeleteSession(sess.TokenHash)
return nil, false
}
return sess, true
}
// maybeRotate 活跃访问距过期 < 3 天时轮换 token(csrf 保持不变,§7.3-5)。
func (s *Server) maybeRotate(w http.ResponseWriter, sess *store.Session) {
remaining := time.Until(time.Unix(sess.ExpiresAt, 0))
if remaining >= rotateThreshold {
return
}
newToken, err := newToken()
if err != nil {
return // 轮换失败不影响本次请求
}
now := time.Now().Unix()
if err := s.st.RotateSession(sess.TokenHash, hashToken(newToken), sess.CSRFToken, now, now+int64(sessionTTL.Seconds())); err != nil {
s.log.Error("轮换会话失败", "err", err)
return
}
http.SetCookie(w, s.sessionCookie(newToken, int(sessionTTL.Seconds())))
}
// ---- 元信息注入(§8.3-4,可见性规则)----
// ResolveMeta 按请求路径为 SPA index.html 计算 meta:
// 仅当笔记可见(public,或管理员会话)才注入该笔记 meta,否则回退站点默认。
func (s *Server) ResolveMeta(r *http.Request) webui.Meta {
ss, err := s.st.GetSiteSettings()
if err != nil {
ss = &store.SiteSettings{SiteTitle: store.DefaultSiteTitle}
}
m := webui.Meta{
Title: ss.SiteTitle,
Description: ss.SiteDesc,
OGTitle: ss.SiteTitle,
OGDescription: ss.SiteDesc,
OGType: "website",
SiteName: ss.SiteTitle,
OGURL: baseURL(r) + "/",
}
slug := metaSlug(r.URL.Path)
if slug == "" {
return m
}
note, err := s.st.GetNoteBySlug(slug)
if err != nil || note.Status != "public" || note.DeletedAt != nil {
// 私有/回收站/不存在:统一回退站点默认 meta(不泄露存在性)
if note != nil {
if _, isAdmin := s.sessionFrom(r); isAdmin {
// 管理员预览私有笔记时允许注入真实 meta
return s.noteMeta(r, m, note)
}
}
return m
}
return s.noteMeta(r, m, note)
}
func (s *Server) noteMeta(r *http.Request, base webui.Meta, n *store.Note) webui.Meta {
desc := n.Summary
if desc == "" {
desc = base.Description
}
base.Title = n.Title + " - " + base.SiteName
base.Description = desc
base.OGTitle = n.Title
base.OGDescription = desc
base.OGType = "article"
base.OGURL = baseURL(r) + "/notes/" + n.Slug
return base
}
// metaSlug 从路径提取 /notes/{slug};非该路径返回空。
func metaSlug(p string) string {
const prefix = "/notes/"
if len(p) > len(prefix) && p[:len(prefix)] == prefix {
rest := p[len(prefix):]
for i := 0; i < len(rest); i++ {
if rest[i] == '/' || rest[i] == '?' {
return rest[:i]
}
}
return rest
}
return ""
}
func baseURL(r *http.Request) string {
scheme := "https"
if r.Header.Get("X-Forwarded-Proto") != "" {
scheme = r.Header.Get("X-Forwarded-Proto")
} else if r.TLS == nil {
scheme = "http"
}
return scheme + "://" + r.Host
}