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
+178
View File
@@ -0,0 +1,178 @@
package httpapi
import (
"errors"
"net/http"
"strconv"
"pure-note/internal/store"
)
// handleHealth GET /api/health。
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
if err := s.st.DB().Ping(); err != nil {
writeError(w, http.StatusInternalServerError, "db_unavailable", "数据库不可用")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// handleMe GET /api/me:匿名 {authenticated:false};已认证 {authenticated:true, csrf_token}。
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
if sess, ok := s.sessionFrom(r); ok {
s.maybeRotate(w, sess)
writeJSON(w, http.StatusOK, map[string]any{"authenticated": true, "csrf_token": sess.CSRFToken})
return
}
writeJSON(w, http.StatusOK, map[string]any{"authenticated": false})
}
// handleSiteInfo GET /api/site:站点设置白名单视图(标题/副标题/每页条数)。
// 永不包含 admin_password_hash(§9.3)。
func (s *Server) handleSiteInfo(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)
}
// publicNoteItem 公开列表项(元信息,不含全文)。
type publicNoteItem struct {
Slug string `json:"slug"`
Title string `json:"title"`
Summary string `json:"summary"`
Tags []string `json:"tags"`
Pinned bool `json:"pinned"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
// handlePublicNotes GET /api/notes:公开笔记列表(可见性过滤在查询层,§9.1-T10)。
func (s *Server) handlePublicNotes(w http.ResponseWriter, r *http.Request) {
page, pageSize, ok := s.parsePagination(r)
if !ok {
writeError(w, http.StatusBadRequest, "bad_request", "分页参数越界(page ∈ [1,10000],page_size ∈ [1,100])")
return
}
tag := r.URL.Query().Get("tag")
notes, total, err := s.st.ListPublicNotes(page, pageSize, tag)
if err != nil {
s.log.Error("查询公开列表失败", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
return
}
items := make([]publicNoteItem, 0, len(notes))
for _, n := range notes {
items = append(items, publicNoteItem{
Slug: n.Slug, Title: n.Title, Summary: n.Summary,
Tags: n.Tags, Pinned: n.Pinned,
CreatedAt: n.CreatedAt, UpdatedAt: n.UpdatedAt,
})
}
writeJSON(w, http.StatusOK, map[string]any{
"items": items, "page": page, "page_size": pageSize, "total": total,
})
}
// handlePublicNote GET /api/notes/{slug}:public 或管理员会话可读;其余统一 404。
func (s *Server) handlePublicNote(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
note, err := s.st.GetNoteBySlug(slug)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "笔记不存在")
return
}
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
return
}
if note.DeletedAt != nil {
// 回收站内容仅经 /api/admin/trash 出口可见(§13)
writeError(w, http.StatusNotFound, "not_found", "笔记不存在")
return
}
if note.Status != "public" {
if _, isAdmin := s.sessionFrom(r); !isAdmin {
// 私有笔记对匿名统一 404(不泄露存在性);管理员可私有预览
writeError(w, http.StatusNotFound, "not_found", "笔记不存在")
return
}
}
prevSlug, prevTitle, _ := s.st.AdjacentPublicNote(note, "prev")
nextSlug, nextTitle, _ := s.st.AdjacentPublicNote(note, "next")
writeJSON(w, http.StatusOK, map[string]any{
"id": note.ID, "slug": note.Slug, "title": note.Title,
"summary": note.Summary, "content": note.Content,
"status": note.Status, "tags": note.Tags, "pinned": note.Pinned,
"created_at": note.CreatedAt, "updated_at": note.UpdatedAt,
"prev": siblingOrEmpty(prevSlug, prevTitle),
"next": siblingOrEmpty(nextSlug, nextTitle),
})
}
func siblingOrEmpty(slug, title string) map[string]any {
if slug == "" {
return nil
}
return map[string]any{"slug": slug, "title": title}
}
// handleTags GET /api/tags:标签聚合(仅公开且未删除)。
func (s *Server) handleTags(w http.ResponseWriter, r *http.Request) {
tags, err := s.st.PublicTags()
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
return
}
if tags == nil {
tags = []store.TagCount{}
}
writeJSON(w, http.StatusOK, map[string]any{"tags": tags})
}
// handleImage GET /api/images/{id}:并集可见性;未授权与不存在统一 404;
// 缓存头按可见性分流(§7.4)。
func (s *Server) handleImage(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil || id <= 0 {
writeError(w, http.StatusNotFound, "not_found", "图片不存在")
return
}
img, err := s.st.GetImageMeta(id)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "图片不存在")
return
}
public, err := s.st.ImageIsPublic(id)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
return
}
if !public {
if _, isAdmin := s.sessionFrom(r); !isAdmin {
writeError(w, http.StatusNotFound, "not_found", "图片不存在")
return
}
// 非公开图:每次请求重新判定,禁止缓存
w.Header().Set("Cache-Control", "private, no-store")
} else {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
}
w.Header().Set("Content-Type", img.MIME)
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("ETag", `"`+img.SHA256+`"`)
if r.Header.Get("If-None-Match") == `"`+img.SHA256+`"` {
w.WriteHeader(http.StatusNotModified)
return
}
data, err := s.st.GetImageData(id)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "图片不存在")
return
}
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
_, _ = w.Write(data)
}