// 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("DELETE /api/admin/trash", s.handleAdminTrashEmpty) 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 }