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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user