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
+139
View File
@@ -0,0 +1,139 @@
// Package webui go:embed 前端产物 + SPA fallback + index.html 元信息注入。
// embed 只能引用本包目录树内文件:产物由 Makefile `sync-assets` 拷贝至
// internal/webui/dist(§8.3-1)。
package webui
import (
"bytes"
"embed"
"fmt"
"html/template"
"io"
"io/fs"
"net/http"
"path"
"strings"
"time"
)
//go:embed all:dist
var distFS embed.FS
// Meta index.html 模板数据。所有字段由服务端填充(含默认回退值),
// 经 html/template 自动转义注入(§8.3-4,防标题内容打断标签结构)。
type Meta struct {
Title string
Description string
OGTitle string
OGDescription string
OGType string
OGURL string
OGImage string
SiteName string
}
// UI 静态资源服务。
type UI struct {
assets fs.FS
indexTmpl *template.Template
hasIndex bool
}
// New 从内嵌产物构造 UI。dist 缺 index.html(M0 占位)时仍可构造,
// HTML 路径回退到占位提示页。
func New() (*UI, error) {
sub, err := fs.Sub(distFS, "dist")
if err != nil {
return nil, err
}
return newFromFS(sub)
}
// newFromFS 供测试注入任意 FS。
func newFromFS(fsys fs.FS) (*UI, error) {
u := &UI{assets: fsys}
index, err := fs.ReadFile(fsys, "index.html")
if err == nil {
tmpl, err := template.New("index").Parse(string(index))
if err != nil {
return nil, fmt.Errorf("解析 index.html 模板失败: %w", err)
}
u.indexTmpl = tmpl
u.hasIndex = true
}
return u, nil
}
var placeholderTmpl = template.Must(template.New("ph").Parse(
`<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>Pure Note</title></head>
<body><h1>Pure Note</h1><p>前端静态资源尚未构建:请在仓库根目录执行 <code>make build</code>(会先构建 web/dist 并拷贝到 internal/webui/dist)。</p></body></html>`))
// Handler 返回 SPA 资源服务:
// 命中文件 → 按指纹长缓存;未命中且无扩展名 → index.html(注入 meta)。
func (u *UI) Handler(meta func(*http.Request) Meta) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
p := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
if p == "" || p == "." {
// 站点根 → index.html
u.serveIndex(w, r, meta)
return
}
if st, err := fs.Stat(u.assets, p); err == nil && !st.IsDir() {
// index.html 直接命中(显式请求)也走模板渲染
if p == "index.html" {
u.serveIndex(w, r, meta)
return
}
f, err := u.assets.Open(p)
if err != nil {
http.NotFound(w, r)
return
}
defer f.Close()
rs, ok := f.(io.ReadSeeker)
if !ok {
http.NotFound(w, r)
return
}
// Vite 产物按内容 hash 命名 → 指纹天然隔离新旧版本(§8.3-5)
if strings.HasPrefix(p, "assets/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "public, max-age=3600")
}
http.ServeContent(w, r, path.Base(p), time.Time{}, rs)
return
}
// 带扩展名的未命中路径(如 /assets/missing.js)→ 404,不回退 HTML
if strings.Contains(path.Base(p), ".") {
http.NotFound(w, r)
return
}
// SPA 深链(/notes/:slug 等)→ index.html
u.serveIndex(w, r, meta)
})
}
func (u *UI) serveIndex(w http.ResponseWriter, r *http.Request, metaFn func(*http.Request) Meta) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
if !u.hasIndex {
w.WriteHeader(http.StatusServiceUnavailable)
_ = placeholderTmpl.Execute(w, nil)
return
}
var m Meta
if metaFn != nil {
m = metaFn(r)
}
var buf bytes.Buffer
if err := u.indexTmpl.Execute(&buf, m); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
_, _ = w.Write(buf.Bytes())
}