Files
pure-note/internal/httpapi/server.go
T
wangairnan 5457aeaa33 feat: 新增发布日期自选、站点 Logo、回收站清空等十项改进
- 编辑页标题改 filled 变体;标签改 tags 选择器(可勾选既有/输入新建)
- 笔记发布日期可自选(schema v2 新增 published_at 并回填),前台展示发布日期
- 登录页移除「请输入管理密码以继续」「连续失败将被暂时锁定」文案
- 站点设置每页条数收敛为 10/20/30/50 选择器,后端白名单同源校验
- 站点设置两卡片宽屏左右/窄屏上下;修改密码按钮改常规大小
- 后台各页顶栏固定高度,切换页面不再抖动
- 站点 Logo:settings 新增 site_logo(站内路径),gc/孤儿清单豁免 Logo 图片,
  博客 header 站点名前展示,设置页支持上传/更换/清除
- 回收站右上角一键清空(DELETE /api/admin/trash,含确认弹窗)
- pn init 写入 Markdown 语法示例文档(slug welcome,公开)与程序生成的示例图片
2026-09-09 11:10:30 +08:00

359 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) + "/",
}
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
}