HTTP 层与单二进制入口:路由 handler、会话/CSRF/限流防线、SPA 嵌入与 meta 注入

- 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 单测
This commit is contained in:
2026-09-08 08:14:23 +08:00
parent 247e88c4fb
commit 6e83426ca9
12 changed files with 2930 additions and 0 deletions
+545
View File
@@ -0,0 +1,545 @@
package httpapi
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io"
"mime"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"pure-note/internal/auth"
"pure-note/internal/markdown"
"pure-note/internal/middleware"
"pure-note/internal/store"
)
// adminNoteItem 管理列表项(不含全文)。
type adminNoteItem struct {
ID int64 `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
Summary string `json:"summary"`
Status string `json:"status"`
Tags []string `json:"tags"`
Pinned bool `json:"pinned"`
DeletedAt *int64 `json:"deleted_at,omitempty"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
func toAdminItem(n *store.Note) adminNoteItem {
return adminNoteItem{
ID: n.ID, Slug: n.Slug, Title: n.Title, Summary: n.Summary,
Status: n.Status, Tags: n.Tags, Pinned: n.Pinned,
DeletedAt: n.DeletedAt, CreatedAt: n.CreatedAt, UpdatedAt: n.UpdatedAt,
}
}
// handleAdminNotes GET /api/admin/notes:全部正常笔记(含私有,不含回收站)。
func (s *Server) handleAdminNotes(w http.ResponseWriter, r *http.Request) {
notes, err := s.st.ListAdminNotes()
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
return
}
items := make([]adminNoteItem, 0, len(notes))
for i := range notes {
items = append(items, toAdminItem(&notes[i]))
}
writeJSON(w, http.StatusOK, map[string]any{"items": items, "total": len(items)})
}
// notePayload 笔记写请求体。
type notePayload struct {
Title string `json:"title"`
Slug string `json:"slug"`
Summary string `json:"summary"`
Content string `json:"content"`
Status string `json:"status"`
Tags []string `json:"tags"`
Pinned bool `json:"pinned"`
}
func (p *notePayload) validate() (string, string) { // 返回 (错误码, 消息)
if strings.TrimSpace(p.Title) == "" {
return "bad_request", "标题不能为空"
}
if len([]rune(p.Title)) > 300 {
return "bad_request", "标题过长(≤300 字符)"
}
if len([]rune(p.Summary)) > 500 {
return "bad_request", "摘要过长(≤500 字符)"
}
if p.Status != "public" && p.Status != "private" {
return "bad_request", "status 必须为 public 或 private"
}
return "", ""
}
// handleAdminNoteCreate POST /api/admin/notes。
// slug 由服务端从标题自动生成并自解冲突(§8.2);首次保存即定稿。
func (s *Server) handleAdminNoteCreate(w http.ResponseWriter, r *http.Request) {
var p notePayload
if err := decodeJSON(r, &p); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "请求体不是合法 JSON")
return
}
if code, msg := p.validate(); code != "" {
writeError(w, http.StatusBadRequest, code, msg)
return
}
slug := s.uniqueSlug(slugify(p.Title, time.Now()))
now := time.Now().Unix()
summary := p.Summary
if strings.TrimSpace(summary) == "" {
summary = markdown.Summary(p.Content, 200)
}
n := &store.Note{
Slug: slug, Title: strings.TrimSpace(p.Title), Summary: summary,
Content: p.Content, Status: p.Status, Tags: store.NormalizeTags(p.Tags),
Pinned: p.Pinned, CreatedAt: now, UpdatedAt: now,
}
id, err := s.st.CreateNote(n)
if err != nil {
s.log.Error("创建笔记失败", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
return
}
if err := s.st.RebuildImageRefs(id, p.Content); err != nil {
s.log.Error("重建图片引用失败", "err", err)
}
n.ID = id
s.log.Info("admin_action", "op", "note.create", "id", id, "slug", slug, "status", n.Status)
writeJSON(w, http.StatusCreated, toAdminItem(n))
}
// handleAdminNoteGet GET /api/admin/notes/{id}:单篇(含私有、含全文)。
func (s *Server) handleAdminNoteGet(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(r)
if !ok {
writeError(w, http.StatusNotFound, "not_found", "笔记不存在")
return
}
n, err := s.st.GetNoteByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "笔记不存在")
return
}
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
return
}
writeJSON(w, http.StatusOK, n)
}
// handleAdminNoteUpdate PUT /api/admin/notes/{id}。
// slug 变更时查重(排除自身),冲突 → 409 + 字段级错误(§8.2)。
func (s *Server) handleAdminNoteUpdate(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(r)
if !ok {
writeError(w, http.StatusNotFound, "not_found", "笔记不存在")
return
}
n, err := s.st.GetNoteByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "笔记不存在")
return
}
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
return
}
if n.DeletedAt != nil {
writeError(w, http.StatusNotFound, "not_found", "笔记在回收站中,请先恢复")
return
}
var p notePayload
if err := decodeJSON(r, &p); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "请求体不是合法 JSON")
return
}
if code, msg := p.validate(); code != "" {
writeError(w, http.StatusBadRequest, code, msg)
return
}
// slug:空 = 保持不变;变更 = 手改,需查重(含回收站占用)
newSlug := strings.TrimSpace(p.Slug)
if newSlug != "" && newSlug != n.Slug {
if !validSlug(newSlug) {
writeError(w, http.StatusBadRequest, "bad_request", "slug 仅允许小写字母、数字与连字符")
return
}
taken, err := s.st.SlugExists(newSlug, id)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
return
}
if taken {
writeErrorFull(w, http.StatusConflict, apiError{
Code: "slug_conflict", Message: "该 slug 已被其他笔记(或回收站中的笔记)占用", Field: "slug",
})
return
}
n.Slug = newSlug
if err := s.st.UpdateNoteSlug(id, newSlug, time.Now().Unix()); err != nil {
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
return
}
}
n.Title = strings.TrimSpace(p.Title)
n.Status = p.Status
n.Tags = store.NormalizeTags(p.Tags)
n.Pinned = p.Pinned
n.Content = p.Content
if strings.TrimSpace(p.Summary) == "" {
n.Summary = markdown.Summary(p.Content, 200)
} else {
n.Summary = p.Summary
}
n.UpdatedAt = time.Now().Unix()
if err := s.st.UpdateNote(n); err != nil {
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
return
}
if err := s.st.RebuildImageRefs(id, p.Content); err != nil {
s.log.Error("重建图片引用失败", "err", err)
}
s.log.Info("admin_action", "op", "note.update", "id", id, "status", n.Status)
writeJSON(w, http.StatusOK, toAdminItem(n))
}
// handleAdminNoteDelete DELETE /api/admin/notes/{id}:软删除进入回收站(§6.2)。
func (s *Server) handleAdminNoteDelete(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(r)
if !ok {
writeError(w, http.StatusNotFound, "not_found", "笔记不存在")
return
}
if err := s.st.SoftDeleteNote(id, time.Now().Unix()); err != nil {
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "笔记不存在")
return
}
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
return
}
s.log.Info("admin_action", "op", "note.trash", "id", id)
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
// handleAdminTrash GET /api/admin/trash:回收站列表。
func (s *Server) handleAdminTrash(w http.ResponseWriter, r *http.Request) {
notes, err := s.st.ListTrash()
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
return
}
items := make([]adminNoteItem, 0, len(notes))
for i := range notes {
items = append(items, toAdminItem(&notes[i]))
}
writeJSON(w, http.StatusOK, map[string]any{"items": items, "total": len(items)})
}
// handleAdminTrashRestore POST /api/admin/trash/{id}/restore:恢复(清空 deleted_at)。
func (s *Server) handleAdminTrashRestore(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(r)
if !ok {
writeError(w, http.StatusNotFound, "not_found", "笔记不存在")
return
}
if err := s.st.RestoreNote(id); err != nil {
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "笔记不存在或不在回收站中")
return
}
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
return
}
s.log.Info("admin_action", "op", "note.restore", "id", id)
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
// ---- 图片上传(§7.4)----
var allowedUploadTypes = map[string]string{
"image/png": ".png",
"image/jpeg": ".jpg",
"image/webp": ".webp",
"image/gif": ".gif",
}
// sniffImageMagic 魔数字节校验(不信任客户端声明;SVG 一律拒绝)。
func sniffImageMagic(b []byte) (mimeTyp string, ok bool) {
switch {
case len(b) >= 8 && b[0] == 0x89 && b[1] == 'P' && b[2] == 'N' && b[3] == 'G' &&
b[4] == 0x0D && b[5] == 0x0A && b[6] == 0x1A && b[7] == 0x0A:
return "image/png", true
case len(b) >= 3 && b[0] == 0xFF && b[1] == 0xD8 && b[2] == 0xFF:
return "image/jpeg", true
case len(b) >= 6 && (string(b[:6]) == "GIF87a" || string(b[:6]) == "GIF89a"):
return "image/gif", true
case len(b) >= 12 && string(b[:4]) == "RIFF" && string(b[8:12]) == "WEBP":
return "image/webp", true
}
return "", false
}
// handleAdminImageUpload POST /api/admin/images:multipart 上传 → {id, url}。
func (s *Server) handleAdminImageUpload(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(maxUploadBody); err != nil {
var mbe *http.MaxBytesError
if errors.As(err, &mbe) {
writeError(w, http.StatusRequestEntityTooLarge, "too_large", "请求体超过上限")
return
}
writeError(w, http.StatusBadRequest, "bad_request", "multipart 解析失败")
return
}
file, header, err := r.FormFile("file")
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "缺少 file 字段")
return
}
defer file.Close()
// 声明类型白名单(之后仍以魔数为准)
declared, _, err := mime.ParseMediaType(header.Header.Get("Content-Type"))
if err != nil || allowedUploadTypes[declared] == "" {
writeError(w, http.StatusUnsupportedMediaType, "unsupported_media", "仅支持 PNG/JPEG/WebP/GIF")
return
}
// 读入内存(≤5MB + 1 字节哨兵判超限)
data, err := io.ReadAll(io.LimitReader(file, maxImageSize+1))
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "读取上传内容失败")
return
}
if len(data) > maxImageSize {
writeError(w, http.StatusRequestEntityTooLarge, "too_large", "图片超过 5MB 上限")
return
}
if len(data) == 0 {
writeError(w, http.StatusBadRequest, "bad_request", "空文件")
return
}
magicMime, ok := sniffImageMagic(data)
if !ok {
writeError(w, http.StatusUnsupportedMediaType, "unsupported_media", "文件内容不是受支持的图片(魔数校验失败,SVG 一律拒绝)")
return
}
// 解码校验(PNG/JPEG/GIF;WebP 由魔数保证)——拦截截断/伪造的图片流
if magicMime != "image/webp" {
if _, _, err := image.Decode(bytes.NewReader(data)); err != nil {
writeError(w, http.StatusUnsupportedMediaType, "unsupported_media", "图片解码失败")
return
}
}
sum := sha256.Sum256(data)
shaHex := hex.EncodeToString(sum[:])
id, err := s.st.UpsertImage(shaHex, magicMime, len(data), data, time.Now().Unix())
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
return
}
s.log.Info("admin_action", "op", "image.upload", "id", id, "sha256", shaHex, "size", len(data))
writeJSON(w, http.StatusCreated, map[string]any{
"id": id,
"url": fmt.Sprintf("/api/images/%d", id),
})
}
// handleAdminImages GET /api/admin/images?orphan=1:0 引用图片清单。
func (s *Server) handleAdminImages(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("orphan") != "1" {
writeError(w, http.StatusBadRequest, "bad_request", "仅支持 ?orphan=1 查询(实际清除由 gc 执行)")
return
}
imgs, err := s.st.ListOrphanImages()
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": imgs, "total": len(imgs)})
}
// ---- 设置(白名单 DTO,§7.1)----
type settingsDTO struct {
SiteTitle *string `json:"site_title"`
SiteDesc *string `json:"site_desc"`
PageSize *int `json:"page_size"`
}
// handleAdminSettingsGet GET /api/admin/settings:白名单三键;永不序列化 admin_password_hash。
func (s *Server) handleAdminSettingsGet(w http.ResponseWriter, r *http.Request) {
ss, err := s.st.GetSiteSettings()
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
return
}
writeJSON(w, http.StatusOK, ss)
}
// handleAdminSettingsPut PUT /api/admin/settings:只收白名单键;未知键拒绝。
func (s *Server) handleAdminSettingsPut(w http.ResponseWriter, r *http.Request) {
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
var dto settingsDTO
if err := dec.Decode(&dto); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "包含未知字段或类型不合法(白名单:site_title/site_desc/page_size)")
return
}
if dto.SiteTitle != nil {
v := strings.TrimSpace(*dto.SiteTitle)
if v == "" || len([]rune(v)) > 200 {
writeError(w, http.StatusBadRequest, "bad_request", "站点标题不能为空且 ≤200 字符")
return
}
if err := s.st.SetSetting(store.KeySiteTitle, v); err != nil {
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
return
}
}
if dto.SiteDesc != nil {
if len([]rune(*dto.SiteDesc)) > 500 {
writeError(w, http.StatusBadRequest, "bad_request", "副标题 ≤500 字符")
return
}
if err := s.st.SetSetting(store.KeySiteDesc, *dto.SiteDesc); err != nil {
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
return
}
}
if dto.PageSize != nil {
if *dto.PageSize < 1 || *dto.PageSize > 100 {
writeError(w, http.StatusBadRequest, "bad_request", "page_size ∈ [1,100]")
return
}
if err := s.st.SetSetting(store.KeyPageSize, strconv.Itoa(*dto.PageSize)); err != nil {
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
return
}
}
// admin_password_hash 不可经此接口写入(§9.3):不在白名单结构体中,天然拒绝。
s.log.Info("admin_action", "op", "settings.update")
ss, err := s.st.GetSiteSettings()
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
return
}
writeJSON(w, http.StatusOK, ss)
}
// ---- 改密(§7.1)----
type passwordPayload struct {
OldPassword string `json:"old_password"`
NewPassword string `json:"new_password"`
}
// handleAdminPassword POST /api/admin/password:校验旧密码(常量时间)、新密码 ≥12 字符;
// 复用登录限流;改密不失效当前会话。
func (s *Server) handleAdminPassword(w http.ResponseWriter, r *http.Request) {
var p passwordPayload
if err := decodeJSON(r, &p); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "请求体不是合法 JSON")
return
}
ip := middleware.ClientIP(r, s.cfg.BehindProxy)
const account = "admin"
if !s.loginIP.Available(ip) || !s.loginAcct.Available(account) {
retry := max(s.loginIP.RetryAfter(ip), s.loginAcct.RetryAfter(account))
w.Header().Set("Retry-After", strconv.Itoa(retry))
writeError(w, http.StatusTooManyRequests, "rate_limited", "尝试过于频繁,请稍后再试")
return
}
hash, ok, err := s.st.GetSetting(store.KeyAdminPasswordHash)
if err != nil || !ok {
writeError(w, http.StatusInternalServerError, "not_initialized", "尚未初始化管理员口令")
return
}
if !auth.VerifyPassword(hash, p.OldPassword) {
s.loginIP.Allow(ip)
s.loginAcct.Allow(account)
s.log.Warn("password_change_failed", "ip", ip, "reason", "old_password_invalid")
writeError(w, http.StatusUnauthorized, "invalid_credentials", "旧密码不正确")
return
}
if err := auth.CheckPasswordStrength(p.NewPassword); err != nil {
writeError(w, http.StatusBadRequest, "weak_password", "新密码长度至少 12 个字符")
return
}
newHash, err := auth.HashPassword(p.NewPassword)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
return
}
if err := s.st.SetSetting(store.KeyAdminPasswordHash, newHash); err != nil {
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
return
}
s.log.Info("admin_action", "op", "password.change", "ip", ip)
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
// ---- slug 策略(§8.2)----
var slugNoiseRe = regexp.MustCompile(`[^a-z0-9]+`)
var slugEdgeRe = regexp.MustCompile(`^-+|-+$`)
// slugify ASCII 标题 → 小写连字符 slug;结果为空(如纯中文)→ post-YYYYMMDD。
func slugify(title string, now time.Time) string {
t := strings.ToLower(strings.TrimSpace(title))
t = strings.ReplaceAll(t, "_", "-")
t = slugNoiseRe.ReplaceAllString(t, "-")
t = slugEdgeRe.ReplaceAllString(t, "")
t = strings.Trim(t, "-")
if t == "" {
return "post-" + now.Format("20060102")
}
if len(t) > 80 {
t = strings.Trim(t[:80], "-")
}
return t
}
// validSlug 用户手改 slug 的格式约束。
func validSlug(s string) bool {
if s == "" || len(s) > 120 {
return false
}
return regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`).MatchString(s)
}
// uniqueSlug 冲突自解:base、base-2、base-3…(查重含回收站,§6.2/§8.2)。
func (s *Server) uniqueSlug(base string) string {
candidate := base
for i := 2; ; i++ {
taken, err := s.st.SlugExists(candidate, 0)
if err != nil {
// 查重失败(DB 异常)时退化为随机后缀,避免与既有记录冲突
rand, rerr := auth.NewToken()
if rerr != nil {
return candidate
}
return base + "-" + rand[:6]
}
if !taken {
return candidate
}
candidate = fmt.Sprintf("%s-%d", base, i)
}
}