Files
pure-note/internal/httpapi/server.go
T
wangairnan 1272d680a8 fix(httpapi): no-store 接线、图片缓存头时序、解压炸弹上限与 RSS 空日期
/api/admin/* 挂 NoStore、/api/me 内联 no-store(P1-3); 图片缓存头移至数据读取成功后,404 不携带 public immutable(P2-9); 上传先 DecodeConfig 限制像素 ≤2^25 再解码(P2-12); 无公开笔记时省略 lastBuildDate(P2-10)。
2026-09-08 17:32:52 +08:00

358 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)))
// NoStore:管理数据(笔记全文/回收站/settings)禁缓存,防登出后 bfcache 回看(§9.2)
mux.Handle("/api/admin/", middleware.NoStore(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
}