Files
pure-note/internal/webui/webui.go
T
wangairnan 6e83426ca9 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 单测
2026-09-08 08:14:23 +08:00

140 lines
3.9 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 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())
}