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:
@@ -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())
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
func testFS() fstest.MapFS {
|
||||
return fstest.MapFS{
|
||||
"index.html": &fstest.MapFile{Data: []byte(`<!doctype html><html><head><title>{{.Title}}</title><meta name="description" content="{{.Description}}"><meta property="og:title" content="{{.OGTitle}}"></head><body><script src="/assets/app-abc123.js"></script></body></html>`)},
|
||||
"assets/app-abc123.js": &fstest.MapFile{Data: []byte("console.log(1)")},
|
||||
"favicon.svg": &fstest.MapFile{Data: []byte("<svg/>")},
|
||||
}
|
||||
}
|
||||
|
||||
func newTestUI(t *testing.T) *UI {
|
||||
t.Helper()
|
||||
u, err := newFromFS(testFS())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func get(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestSPAAndMeta(t *testing.T) {
|
||||
u := newTestUI(t)
|
||||
meta := func(r *http.Request) Meta {
|
||||
if strings.HasPrefix(r.URL.Path, "/notes/hello") {
|
||||
return Meta{Title: "笔记标题 - 站点", Description: "摘要", OGTitle: "笔记标题", OGType: "article", OGURL: "http://x/notes/hello", SiteName: "站点"}
|
||||
}
|
||||
return Meta{Title: "站点", Description: "默认", OGTitle: "站点", OGType: "website", SiteName: "站点"}
|
||||
}
|
||||
h := u.Handler(meta)
|
||||
|
||||
// 根路径:默认 meta
|
||||
rec := get(t, h, "/")
|
||||
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "<title>站点</title>") {
|
||||
t.Errorf("根路径应渲染默认 meta: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// SPA 深链:注入笔记 meta
|
||||
rec = get(t, h, "/notes/hello")
|
||||
if !strings.Contains(rec.Body.String(), "<title>笔记标题 - 站点</title>") {
|
||||
t.Errorf("深链应注入笔记 meta: %s", rec.Body.String())
|
||||
}
|
||||
// html/template 自动转义:标题含恶意内容不破坏标签结构
|
||||
h2 := u.Handler(func(r *http.Request) Meta {
|
||||
return Meta{Title: `<script>alert(1)</script>`, OGTitle: `" onclick="x`}
|
||||
})
|
||||
rec = get(t, h2, "/notes/evil")
|
||||
body := rec.Body.String()
|
||||
if strings.Contains(body, "<script>alert") {
|
||||
t.Errorf("meta 注入必须转义: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, "<script>") {
|
||||
t.Errorf("应含转义后的实体: %s", body)
|
||||
}
|
||||
// 指纹资产:200 + 正确 MIME + immutable 缓存
|
||||
rec = get(t, h, "/assets/app-abc123.js")
|
||||
if rec.Code != 200 {
|
||||
t.Errorf("资产应 200: %d", rec.Code)
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "javascript") {
|
||||
t.Errorf("JS MIME 错误: %s", ct)
|
||||
}
|
||||
if cc := rec.Header().Get("Cache-Control"); cc != "public, max-age=31536000, immutable" {
|
||||
t.Errorf("指纹资产应 immutable: %s", cc)
|
||||
}
|
||||
// 缺失资产(带扩展名)→ 404,不回退 HTML
|
||||
rec = get(t, h, "/assets/missing-abc.js")
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("缺失资产应 404: %d", rec.Code)
|
||||
}
|
||||
// 无扩展名深链 → 回退 index.html
|
||||
rec = get(t, h, "/tags/some-tag")
|
||||
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "<title>") {
|
||||
t.Errorf("无扩展名深链应回退 index.html: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaceholderWhenNoIndex(t *testing.T) {
|
||||
u, err := newFromFS(fstest.MapFS{"dist/.keep": &fstest.MapFile{Data: []byte("")}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := get(t, u.Handler(nil), "/")
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("无 index.html 应 503 占位: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonGetRejected(t *testing.T) {
|
||||
u := newTestUI(t)
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
u.Handler(nil).ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("非 GET 应 405: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user