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
+317
View File
@@ -0,0 +1,317 @@
// pure-note 单二进制入口:serve / init / backup / gc / version(§7.6)。
package main
import (
"bufio"
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"golang.org/x/term"
"pure-note/internal/auth"
"pure-note/internal/config"
"pure-note/internal/httpapi"
"pure-note/internal/store"
"pure-note/internal/webui"
)
// 构建时注入(Makefile -ldflags)。
var (
version = "dev"
commit = "none"
buildDate = "unknown"
)
func main() {
if len(os.Args) < 2 {
usage()
os.Exit(2)
}
var err error
switch os.Args[1] {
case "serve":
err = runServe(os.Args[2:])
case "init":
err = runInit(os.Args[2:])
case "backup":
err = runBackup(os.Args[2:])
case "gc":
err = runGC(os.Args[2:])
case "version":
fmt.Printf("pure-note %s (commit=%s, built=%s)\n", version, commit, buildDate)
case "help", "-h", "--help":
usage()
default:
fmt.Fprintf(os.Stderr, "未知子命令: %s\n\n", os.Args[1])
usage()
os.Exit(2)
}
if err != nil {
fmt.Fprintln(os.Stderr, "错误:", err)
os.Exit(1)
}
}
func usage() {
fmt.Print(`pure-note — 极简高安全私人笔记 + 博客
用法:
pure-note serve [--addr :8080] [--data-dir ./data] [--behind-proxy] [--dev] [--allow-newer]
pure-note init [--data-dir ./data]
pure-note backup [--data-dir ./data] [输出路径]
pure-note gc [--data-dir ./data] [--commit] # 默认 dry-run
pure-note version
`)
}
func newLogger(cfg *config.Config) *slog.Logger {
level := slog.LevelInfo
switch strings.ToLower(cfg.LogLevel) {
case "debug":
level = slog.LevelDebug
case "warn":
level = slog.LevelWarn
case "error":
level = slog.LevelError
}
opts := &slog.HandlerOptions{Level: level}
var h slog.Handler
if cfg.LogFormat == "json" {
h = slog.NewJSONHandler(os.Stderr, opts)
} else {
h = slog.NewTextHandler(os.Stderr, opts)
}
return slog.New(h)
}
// runServe 启动 HTTP 服务(§7.2 服务器参数 + §10.5 优雅停机)。
func runServe(args []string) error {
cfg, err := config.ParseServe(args)
if err != nil {
return err
}
log := newLogger(cfg)
if err := os.MkdirAll(cfg.DataDir, 0o755); err != nil {
return err
}
st, err := store.Open(cfg.DBPath(), cfg.AllowNewer)
if err != nil {
return err
}
defer st.Close()
// 幂等启动守卫:未初始化拒绝启动(§10.2)
if _, ok, err := st.GetSetting("admin_password_hash"); err != nil {
return err
} else if !ok {
return errors.New("尚未初始化管理员口令:请先执行 `pure-note init --data-dir " + cfg.DataDir + "`")
}
srv := httpapi.New(st, cfg, log)
ui, err := webui.New()
if err != nil {
return err
}
httpServer := &http.Server{
Addr: cfg.Addr,
Handler: srv.Handler(ui.Handler(srv.ResolveMeta)),
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 120 * time.Second,
WriteTimeout: 120 * time.Second,
IdleTimeout: 120 * time.Second,
}
// 每小时清理过期会话(§6.2)
stop := make(chan struct{})
go func() {
t := time.NewTicker(time.Hour)
defer t.Stop()
for {
select {
case <-t.C:
if n, err := st.DeleteExpiredSessions(time.Now().Unix()); err == nil && n > 0 {
log.Info("清理过期会话", "count", n)
}
case <-stop:
return
}
}
}()
// 优雅停机:SIGTERM/SIGINT → Shutdown(10s) → 关库
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer cancel()
errCh := make(chan error, 1)
go func() {
log.Info("pure-note 已启动", "addr", cfg.Addr, "data_dir", cfg.DataDir, "dev", cfg.Dev)
errCh <- httpServer.ListenAndServe()
}()
select {
case err := <-errCh:
if !errors.Is(err, http.ErrServerClosed) {
close(stop)
return err
}
case <-ctx.Done():
log.Info("收到停止信号,开始优雅停机…")
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownCancel()
_ = httpServer.Shutdown(shutdownCtx)
}
close(stop)
return st.Close()
}
// runInit 首次初始化:设口令(Argon2id+PHC)与站点标题(§7.3-1)。
func runInit(args []string) error {
cfg, err := config.ParseInit(args)
if err != nil {
return err
}
if err := os.MkdirAll(cfg.DataDir, 0o755); err != nil {
return err
}
st, err := store.Open(cfg.DBPath(), cfg.AllowNewer)
if err != nil {
return err
}
defer st.Close()
if _, ok, err := st.GetSetting(store.KeyAdminPasswordHash); err != nil {
return err
} else if ok {
return errors.New("管理员口令已设置;如需修改请在登录后通过管理界面改密")
}
var password string
if env := os.Getenv("PN_ADMIN_PASSWORD"); env != "" {
password = env
fmt.Fprintln(os.Stderr, "已从环境变量 PN_ADMIN_PASSWORD 读取口令")
} else {
password, err = promptPassword()
if err != nil {
return err
}
}
if err := auth.CheckPasswordStrength(password); err != nil {
return err
}
hash, err := auth.HashPassword(password)
if err != nil {
return err
}
title := strings.TrimSpace(os.Getenv("PN_SITE_TITLE"))
if title == "" {
title = promptLine("站点标题", store.DefaultSiteTitle)
}
if err := st.SetSetting(store.KeyAdminPasswordHash, hash); err != nil {
return err
}
if err := st.SetSetting(store.KeySiteTitle, title); err != nil {
return err
}
fmt.Println("初始化完成。现在可以启动:pure-note serve --data-dir", cfg.DataDir)
return nil
}
func promptPassword() (string, error) {
fmt.Print("设置管理员口令(≥12 字符): ")
b1, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Println()
if err != nil {
return "", err
}
fmt.Print("再次输入确认: ")
b2, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Println()
if err != nil {
return "", err
}
if string(b1) != string(b2) {
return "", errors.New("两次输入不一致")
}
return string(b1), nil
}
func promptLine(label, def string) string {
fmt.Printf("%s [%s]: ", label, def)
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
if err != nil || strings.TrimSpace(line) == "" {
return def
}
return strings.TrimSpace(line)
}
// runBackup 在线备份:VACUUM INTO 一致快照(§10.3)。默认输出到数据目录,0600。
func runBackup(args []string) error {
cfg, rest, err := config.ParseMaint("backup", args)
if err != nil {
return err
}
dest := ""
if len(rest) > 0 {
dest = rest[0]
}
if dest == "" {
dest = filepath.Join(cfg.DataDir, fmt.Sprintf("pure-note-backup-%s.db", time.Now().Format("20060102-150405")))
}
st, err := store.Open(cfg.DBPath(), false)
if err != nil {
return err
}
defer st.Close()
if err := st.Backup(dest); err != nil {
return err
}
fmt.Println("备份完成:", dest)
return nil
}
// runGC 回收站/孤儿图片/过期会话清理;默认 dry-run,--commit 才执行(§6.3)。
func runGC(args []string) error {
cfg, _, err := config.ParseMaint("gc", args)
if err != nil {
return err
}
commit := false
for _, a := range args {
if a == "--commit" {
commit = true
}
}
st, err := store.Open(cfg.DBPath(), false)
if err != nil {
return err
}
defer st.Close()
rep, err := st.GC(time.Now(), !commit)
if err != nil {
return err
}
mode := "dry-run(未改动数据;加 --commit 执行)"
if commit {
mode = "已提交"
}
fmt.Printf("gc 报告(%s):\n", mode)
fmt.Printf(" 过期回收站笔记(>30 天): %d 篇\n", len(rep.ExpiredNotes))
for _, n := range rep.ExpiredNotes {
fmt.Printf(" - id=%d slug=%s deleted_at=%s\n", n.ID, n.Slug, time.Unix(n.DeletedAt, 0).Format("2006-01-02"))
}
fmt.Printf(" 孤儿图片(>7 天宽限期): %d 张\n", len(rep.OrphanImages))
for _, img := range rep.OrphanImages {
fmt.Printf(" - id=%d %s %d bytes\n", img.ID, img.MIME, img.Size)
}
fmt.Printf(" 过期会话: %d 条\n", rep.ExpiredSessons)
return nil
}