Files
pure-note/internal/httpapi/server.go
T
wangairnan 5bac272f6d feat: 前台标签接入 antd 组件、列表封面图、favicon 随站点 Logo 及侧栏收窄
- 前台列表/详情/标签页标签统一 antd Tag;标签页改 CheckableTagGroup 单选
  (选中态由组件承接,再点已选标签回到全部);移除首页标签筛选条,
  标签入口收敛至顶部「标签」页
- 公开列表新增 cover 字段(取正文第一张站内图,store.FirstImageRef),
  首页条目左侧显示封面缩略图,无图条目保持纯文字
- favicon 随站点 Logo:webui.Meta.Icon 服务端注入 index.html,
  前端 useSiteTitle 兜底(dev 模板占位符场景)并即时生效;
  设置页「重置默认」清空 Logo 后回退内置 /favicon.svg
- 后台侧栏改 antd Menu(inline 分组),宽度 256→208、窄侧栏档 224→184
2026-09-09 11:51:06 +08:00

368 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("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) + "/",
Icon: favicon(ss.SiteLogo),
}
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)
}
// favicon favicon 地址:站点 Logo 优先,未设置回退内置图标。
func favicon(siteLogo string) string {
if siteLogo != "" {
return siteLogo
}
return "/favicon.svg"
}
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
}