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,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
|
||||
}
|
||||
Reference in New Issue
Block a user