- 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 单测
357 lines
11 KiB
Go
357 lines
11 KiB
Go
// 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
|
||
}
|