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
+129
View File
@@ -0,0 +1,129 @@
package httpapi
import (
"encoding/xml"
"net/http"
"time"
"pure-note/internal/markdown"
)
// ---- RSS 2.0(§7.1:仅公开;服务端 goldmark+bluemonday 渲染)----
type rssFeed struct {
XMLName xml.Name `xml:"rss"`
Version string `xml:"version,attr"`
Channel rssChannel `xml:"channel"`
}
type rssChannel struct {
Title string `xml:"title"`
Link string `xml:"link"`
Description string `xml:"description"`
Language string `xml:"language,omitempty"`
LastBuild string `xml:"lastBuildDate,omitempty"`
Items []rssItem `xml:"item"`
}
type rssItem struct {
Title string `xml:"title"`
Link string `xml:"link"`
GUID string `xml:"guid"`
PubDate string `xml:"pubDate"` // RFC 822(RSS 2.0 规范)
Description string `xml:"description"` // 已清洗的 HTML(经 encoding/xml 自动转义)
}
// feedItemLimit RSS 条目上限(个人规模足够,避免全量渲染)。
const feedItemLimit = 50
func (s *Server) handleRSS(w http.ResponseWriter, r *http.Request) {
ss, err := s.st.GetSiteSettings()
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
return
}
base := baseURL(r)
notes, err := s.st.ListPublicNotesFull(feedItemLimit)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
return
}
items := make([]rssItem, 0, len(notes))
var lastBuild time.Time
for _, n := range notes {
// 服务端渲染:goldmark(默认转义)→ bluemonday 白名单(§7.5)
html := markdown.Render(n.Content)
pub := time.Unix(n.UpdatedAt, 0)
if pub.After(lastBuild) {
lastBuild = pub
}
items = append(items, rssItem{
Title: n.Title,
Link: base + "/notes/" + n.Slug,
GUID: base + "/notes/" + n.Slug,
PubDate: pub.Format(time.RFC1123Z),
Description: html,
})
}
feed := rssFeed{
Version: "2.0",
Channel: rssChannel{
Title: ss.SiteTitle,
Link: base + "/",
Description: ss.SiteDesc,
Language: "zh-CN",
LastBuild: lastBuild.Format(time.RFC1123Z),
Items: items,
},
}
w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=300")
w.Write([]byte(xml.Header))
enc := xml.NewEncoder(w)
enc.Indent("", " ")
_ = enc.Encode(feed)
}
// ---- sitemap(仅公开笔记)----
type urlSet struct {
XMLName xml.Name `xml:"urlset"`
XMLNS string `xml:"xmlns,attr"`
URLs []siteURL `xml:"url"`
}
type siteURL struct {
Loc string `xml:"loc"`
LastMod time.Time `xml:"lastmod,omitempty"`
}
func (s *Server) handleSitemap(w http.ResponseWriter, r *http.Request) {
base := baseURL(r)
set := urlSet{XMLNS: "http://www.sitemaps.org/schemas/sitemap/0.9"}
set.URLs = append(set.URLs, siteURL{Loc: base + "/"})
notes, _, err := s.st.ListPublicNotes(1, 10000, "")
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
return
}
for _, n := range notes {
set.URLs = append(set.URLs, siteURL{
Loc: base + "/notes/" + n.Slug,
LastMod: time.Unix(n.UpdatedAt, 0),
})
}
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=3600")
w.Write([]byte(xml.Header))
enc := xml.NewEncoder(w)
enc.Indent("", " ")
_ = enc.Encode(set)
}
// ---- robots.txt ----
func (s *Server) handleRobots(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=86400")
w.Write([]byte("User-agent: *\nAllow: /\nDisallow: /admin\n\nSitemap: " + baseURL(r) + "/sitemap.xml\n"))
}