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
|
||||||
|
}
|
||||||
@@ -0,0 +1,545 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
_ "image/gif"
|
||||||
|
_ "image/jpeg"
|
||||||
|
_ "image/png"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pure-note/internal/auth"
|
||||||
|
"pure-note/internal/markdown"
|
||||||
|
"pure-note/internal/middleware"
|
||||||
|
"pure-note/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// adminNoteItem 管理列表项(不含全文)。
|
||||||
|
type adminNoteItem struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
Pinned bool `json:"pinned"`
|
||||||
|
DeletedAt *int64 `json:"deleted_at,omitempty"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func toAdminItem(n *store.Note) adminNoteItem {
|
||||||
|
return adminNoteItem{
|
||||||
|
ID: n.ID, Slug: n.Slug, Title: n.Title, Summary: n.Summary,
|
||||||
|
Status: n.Status, Tags: n.Tags, Pinned: n.Pinned,
|
||||||
|
DeletedAt: n.DeletedAt, CreatedAt: n.CreatedAt, UpdatedAt: n.UpdatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAdminNotes GET /api/admin/notes:全部正常笔记(含私有,不含回收站)。
|
||||||
|
func (s *Server) handleAdminNotes(w http.ResponseWriter, r *http.Request) {
|
||||||
|
notes, err := s.st.ListAdminNotes()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items := make([]adminNoteItem, 0, len(notes))
|
||||||
|
for i := range notes {
|
||||||
|
items = append(items, toAdminItem(¬es[i]))
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"items": items, "total": len(items)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// notePayload 笔记写请求体。
|
||||||
|
type notePayload struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
Pinned bool `json:"pinned"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *notePayload) validate() (string, string) { // 返回 (错误码, 消息)
|
||||||
|
if strings.TrimSpace(p.Title) == "" {
|
||||||
|
return "bad_request", "标题不能为空"
|
||||||
|
}
|
||||||
|
if len([]rune(p.Title)) > 300 {
|
||||||
|
return "bad_request", "标题过长(≤300 字符)"
|
||||||
|
}
|
||||||
|
if len([]rune(p.Summary)) > 500 {
|
||||||
|
return "bad_request", "摘要过长(≤500 字符)"
|
||||||
|
}
|
||||||
|
if p.Status != "public" && p.Status != "private" {
|
||||||
|
return "bad_request", "status 必须为 public 或 private"
|
||||||
|
}
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAdminNoteCreate POST /api/admin/notes。
|
||||||
|
// slug 由服务端从标题自动生成并自解冲突(§8.2);首次保存即定稿。
|
||||||
|
func (s *Server) handleAdminNoteCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var p notePayload
|
||||||
|
if err := decodeJSON(r, &p); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "bad_request", "请求体不是合法 JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if code, msg := p.validate(); code != "" {
|
||||||
|
writeError(w, http.StatusBadRequest, code, msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slug := s.uniqueSlug(slugify(p.Title, time.Now()))
|
||||||
|
now := time.Now().Unix()
|
||||||
|
summary := p.Summary
|
||||||
|
if strings.TrimSpace(summary) == "" {
|
||||||
|
summary = markdown.Summary(p.Content, 200)
|
||||||
|
}
|
||||||
|
n := &store.Note{
|
||||||
|
Slug: slug, Title: strings.TrimSpace(p.Title), Summary: summary,
|
||||||
|
Content: p.Content, Status: p.Status, Tags: store.NormalizeTags(p.Tags),
|
||||||
|
Pinned: p.Pinned, CreatedAt: now, UpdatedAt: now,
|
||||||
|
}
|
||||||
|
id, err := s.st.CreateNote(n)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("创建笔记失败", "err", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.st.RebuildImageRefs(id, p.Content); err != nil {
|
||||||
|
s.log.Error("重建图片引用失败", "err", err)
|
||||||
|
}
|
||||||
|
n.ID = id
|
||||||
|
s.log.Info("admin_action", "op", "note.create", "id", id, "slug", slug, "status", n.Status)
|
||||||
|
writeJSON(w, http.StatusCreated, toAdminItem(n))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAdminNoteGet GET /api/admin/notes/{id}:单篇(含私有、含全文)。
|
||||||
|
func (s *Server) handleAdminNoteGet(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := pathID(r)
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "笔记不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n, err := s.st.GetNoteByID(id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "笔记不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAdminNoteUpdate PUT /api/admin/notes/{id}。
|
||||||
|
// slug 变更时查重(排除自身),冲突 → 409 + 字段级错误(§8.2)。
|
||||||
|
func (s *Server) handleAdminNoteUpdate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := pathID(r)
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "笔记不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n, err := s.st.GetNoteByID(id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "笔记不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n.DeletedAt != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "笔记在回收站中,请先恢复")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var p notePayload
|
||||||
|
if err := decodeJSON(r, &p); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "bad_request", "请求体不是合法 JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if code, msg := p.validate(); code != "" {
|
||||||
|
writeError(w, http.StatusBadRequest, code, msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// slug:空 = 保持不变;变更 = 手改,需查重(含回收站占用)
|
||||||
|
newSlug := strings.TrimSpace(p.Slug)
|
||||||
|
if newSlug != "" && newSlug != n.Slug {
|
||||||
|
if !validSlug(newSlug) {
|
||||||
|
writeError(w, http.StatusBadRequest, "bad_request", "slug 仅允许小写字母、数字与连字符")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
taken, err := s.st.SlugExists(newSlug, id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if taken {
|
||||||
|
writeErrorFull(w, http.StatusConflict, apiError{
|
||||||
|
Code: "slug_conflict", Message: "该 slug 已被其他笔记(或回收站中的笔记)占用", Field: "slug",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n.Slug = newSlug
|
||||||
|
if err := s.st.UpdateNoteSlug(id, newSlug, time.Now().Unix()); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
n.Title = strings.TrimSpace(p.Title)
|
||||||
|
n.Status = p.Status
|
||||||
|
n.Tags = store.NormalizeTags(p.Tags)
|
||||||
|
n.Pinned = p.Pinned
|
||||||
|
n.Content = p.Content
|
||||||
|
if strings.TrimSpace(p.Summary) == "" {
|
||||||
|
n.Summary = markdown.Summary(p.Content, 200)
|
||||||
|
} else {
|
||||||
|
n.Summary = p.Summary
|
||||||
|
}
|
||||||
|
n.UpdatedAt = time.Now().Unix()
|
||||||
|
if err := s.st.UpdateNote(n); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.st.RebuildImageRefs(id, p.Content); err != nil {
|
||||||
|
s.log.Error("重建图片引用失败", "err", err)
|
||||||
|
}
|
||||||
|
s.log.Info("admin_action", "op", "note.update", "id", id, "status", n.Status)
|
||||||
|
writeJSON(w, http.StatusOK, toAdminItem(n))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAdminNoteDelete DELETE /api/admin/notes/{id}:软删除进入回收站(§6.2)。
|
||||||
|
func (s *Server) handleAdminNoteDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := pathID(r)
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "笔记不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.st.SoftDeleteNote(id, time.Now().Unix()); err != nil {
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "笔记不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.log.Info("admin_action", "op", "note.trash", "id", id)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAdminTrash GET /api/admin/trash:回收站列表。
|
||||||
|
func (s *Server) handleAdminTrash(w http.ResponseWriter, r *http.Request) {
|
||||||
|
notes, err := s.st.ListTrash()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items := make([]adminNoteItem, 0, len(notes))
|
||||||
|
for i := range notes {
|
||||||
|
items = append(items, toAdminItem(¬es[i]))
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"items": items, "total": len(items)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAdminTrashRestore POST /api/admin/trash/{id}/restore:恢复(清空 deleted_at)。
|
||||||
|
func (s *Server) handleAdminTrashRestore(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := pathID(r)
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "笔记不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.st.RestoreNote(id); err != nil {
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "笔记不存在或不在回收站中")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.log.Info("admin_action", "op", "note.restore", "id", id)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 图片上传(§7.4)----
|
||||||
|
|
||||||
|
var allowedUploadTypes = map[string]string{
|
||||||
|
"image/png": ".png",
|
||||||
|
"image/jpeg": ".jpg",
|
||||||
|
"image/webp": ".webp",
|
||||||
|
"image/gif": ".gif",
|
||||||
|
}
|
||||||
|
|
||||||
|
// sniffImageMagic 魔数字节校验(不信任客户端声明;SVG 一律拒绝)。
|
||||||
|
func sniffImageMagic(b []byte) (mimeTyp string, ok bool) {
|
||||||
|
switch {
|
||||||
|
case len(b) >= 8 && b[0] == 0x89 && b[1] == 'P' && b[2] == 'N' && b[3] == 'G' &&
|
||||||
|
b[4] == 0x0D && b[5] == 0x0A && b[6] == 0x1A && b[7] == 0x0A:
|
||||||
|
return "image/png", true
|
||||||
|
case len(b) >= 3 && b[0] == 0xFF && b[1] == 0xD8 && b[2] == 0xFF:
|
||||||
|
return "image/jpeg", true
|
||||||
|
case len(b) >= 6 && (string(b[:6]) == "GIF87a" || string(b[:6]) == "GIF89a"):
|
||||||
|
return "image/gif", true
|
||||||
|
case len(b) >= 12 && string(b[:4]) == "RIFF" && string(b[8:12]) == "WEBP":
|
||||||
|
return "image/webp", true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAdminImageUpload POST /api/admin/images:multipart 上传 → {id, url}。
|
||||||
|
func (s *Server) handleAdminImageUpload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseMultipartForm(maxUploadBody); err != nil {
|
||||||
|
var mbe *http.MaxBytesError
|
||||||
|
if errors.As(err, &mbe) {
|
||||||
|
writeError(w, http.StatusRequestEntityTooLarge, "too_large", "请求体超过上限")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, http.StatusBadRequest, "bad_request", "multipart 解析失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file, header, err := r.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "bad_request", "缺少 file 字段")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// 声明类型白名单(之后仍以魔数为准)
|
||||||
|
declared, _, err := mime.ParseMediaType(header.Header.Get("Content-Type"))
|
||||||
|
if err != nil || allowedUploadTypes[declared] == "" {
|
||||||
|
writeError(w, http.StatusUnsupportedMediaType, "unsupported_media", "仅支持 PNG/JPEG/WebP/GIF")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 读入内存(≤5MB + 1 字节哨兵判超限)
|
||||||
|
data, err := io.ReadAll(io.LimitReader(file, maxImageSize+1))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "bad_request", "读取上传内容失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(data) > maxImageSize {
|
||||||
|
writeError(w, http.StatusRequestEntityTooLarge, "too_large", "图片超过 5MB 上限")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(data) == 0 {
|
||||||
|
writeError(w, http.StatusBadRequest, "bad_request", "空文件")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
magicMime, ok := sniffImageMagic(data)
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusUnsupportedMediaType, "unsupported_media", "文件内容不是受支持的图片(魔数校验失败,SVG 一律拒绝)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 解码校验(PNG/JPEG/GIF;WebP 由魔数保证)——拦截截断/伪造的图片流
|
||||||
|
if magicMime != "image/webp" {
|
||||||
|
if _, _, err := image.Decode(bytes.NewReader(data)); err != nil {
|
||||||
|
writeError(w, http.StatusUnsupportedMediaType, "unsupported_media", "图片解码失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(data)
|
||||||
|
shaHex := hex.EncodeToString(sum[:])
|
||||||
|
id, err := s.st.UpsertImage(shaHex, magicMime, len(data), data, time.Now().Unix())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.log.Info("admin_action", "op", "image.upload", "id", id, "sha256", shaHex, "size", len(data))
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]any{
|
||||||
|
"id": id,
|
||||||
|
"url": fmt.Sprintf("/api/images/%d", id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAdminImages GET /api/admin/images?orphan=1:0 引用图片清单。
|
||||||
|
func (s *Server) handleAdminImages(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Query().Get("orphan") != "1" {
|
||||||
|
writeError(w, http.StatusBadRequest, "bad_request", "仅支持 ?orphan=1 查询(实际清除由 gc 执行)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
imgs, err := s.st.ListOrphanImages()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"items": imgs, "total": len(imgs)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 设置(白名单 DTO,§7.1)----
|
||||||
|
|
||||||
|
type settingsDTO struct {
|
||||||
|
SiteTitle *string `json:"site_title"`
|
||||||
|
SiteDesc *string `json:"site_desc"`
|
||||||
|
PageSize *int `json:"page_size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAdminSettingsGet GET /api/admin/settings:白名单三键;永不序列化 admin_password_hash。
|
||||||
|
func (s *Server) handleAdminSettingsGet(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ss, err := s.st.GetSiteSettings()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, ss)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAdminSettingsPut PUT /api/admin/settings:只收白名单键;未知键拒绝。
|
||||||
|
func (s *Server) handleAdminSettingsPut(w http.ResponseWriter, r *http.Request) {
|
||||||
|
dec := json.NewDecoder(r.Body)
|
||||||
|
dec.DisallowUnknownFields()
|
||||||
|
var dto settingsDTO
|
||||||
|
if err := dec.Decode(&dto); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "bad_request", "包含未知字段或类型不合法(白名单:site_title/site_desc/page_size)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if dto.SiteTitle != nil {
|
||||||
|
v := strings.TrimSpace(*dto.SiteTitle)
|
||||||
|
if v == "" || len([]rune(v)) > 200 {
|
||||||
|
writeError(w, http.StatusBadRequest, "bad_request", "站点标题不能为空且 ≤200 字符")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.st.SetSetting(store.KeySiteTitle, v); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if dto.SiteDesc != nil {
|
||||||
|
if len([]rune(*dto.SiteDesc)) > 500 {
|
||||||
|
writeError(w, http.StatusBadRequest, "bad_request", "副标题 ≤500 字符")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.st.SetSetting(store.KeySiteDesc, *dto.SiteDesc); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if dto.PageSize != nil {
|
||||||
|
if *dto.PageSize < 1 || *dto.PageSize > 100 {
|
||||||
|
writeError(w, http.StatusBadRequest, "bad_request", "page_size ∈ [1,100]")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.st.SetSetting(store.KeyPageSize, strconv.Itoa(*dto.PageSize)); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// admin_password_hash 不可经此接口写入(§9.3):不在白名单结构体中,天然拒绝。
|
||||||
|
s.log.Info("admin_action", "op", "settings.update")
|
||||||
|
ss, err := s.st.GetSiteSettings()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, ss)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 改密(§7.1)----
|
||||||
|
|
||||||
|
type passwordPayload struct {
|
||||||
|
OldPassword string `json:"old_password"`
|
||||||
|
NewPassword string `json:"new_password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAdminPassword POST /api/admin/password:校验旧密码(常量时间)、新密码 ≥12 字符;
|
||||||
|
// 复用登录限流;改密不失效当前会话。
|
||||||
|
func (s *Server) handleAdminPassword(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var p passwordPayload
|
||||||
|
if err := decodeJSON(r, &p); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "bad_request", "请求体不是合法 JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ip := middleware.ClientIP(r, s.cfg.BehindProxy)
|
||||||
|
const account = "admin"
|
||||||
|
if !s.loginIP.Available(ip) || !s.loginAcct.Available(account) {
|
||||||
|
retry := max(s.loginIP.RetryAfter(ip), s.loginAcct.RetryAfter(account))
|
||||||
|
w.Header().Set("Retry-After", strconv.Itoa(retry))
|
||||||
|
writeError(w, http.StatusTooManyRequests, "rate_limited", "尝试过于频繁,请稍后再试")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hash, ok, err := s.st.GetSetting(store.KeyAdminPasswordHash)
|
||||||
|
if err != nil || !ok {
|
||||||
|
writeError(w, http.StatusInternalServerError, "not_initialized", "尚未初始化管理员口令")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !auth.VerifyPassword(hash, p.OldPassword) {
|
||||||
|
s.loginIP.Allow(ip)
|
||||||
|
s.loginAcct.Allow(account)
|
||||||
|
s.log.Warn("password_change_failed", "ip", ip, "reason", "old_password_invalid")
|
||||||
|
writeError(w, http.StatusUnauthorized, "invalid_credentials", "旧密码不正确")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := auth.CheckPasswordStrength(p.NewPassword); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "weak_password", "新密码长度至少 12 个字符")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
newHash, err := auth.HashPassword(p.NewPassword)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.st.SetSetting(store.KeyAdminPasswordHash, newHash); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.log.Info("admin_action", "op", "password.change", "ip", ip)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- slug 策略(§8.2)----
|
||||||
|
|
||||||
|
var slugNoiseRe = regexp.MustCompile(`[^a-z0-9]+`)
|
||||||
|
var slugEdgeRe = regexp.MustCompile(`^-+|-+$`)
|
||||||
|
|
||||||
|
// slugify ASCII 标题 → 小写连字符 slug;结果为空(如纯中文)→ post-YYYYMMDD。
|
||||||
|
func slugify(title string, now time.Time) string {
|
||||||
|
t := strings.ToLower(strings.TrimSpace(title))
|
||||||
|
t = strings.ReplaceAll(t, "_", "-")
|
||||||
|
t = slugNoiseRe.ReplaceAllString(t, "-")
|
||||||
|
t = slugEdgeRe.ReplaceAllString(t, "")
|
||||||
|
t = strings.Trim(t, "-")
|
||||||
|
if t == "" {
|
||||||
|
return "post-" + now.Format("20060102")
|
||||||
|
}
|
||||||
|
if len(t) > 80 {
|
||||||
|
t = strings.Trim(t[:80], "-")
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// validSlug 用户手改 slug 的格式约束。
|
||||||
|
func validSlug(s string) bool {
|
||||||
|
if s == "" || len(s) > 120 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`).MatchString(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// uniqueSlug 冲突自解:base、base-2、base-3…(查重含回收站,§6.2/§8.2)。
|
||||||
|
func (s *Server) uniqueSlug(base string) string {
|
||||||
|
candidate := base
|
||||||
|
for i := 2; ; i++ {
|
||||||
|
taken, err := s.st.SlugExists(candidate, 0)
|
||||||
|
if err != nil {
|
||||||
|
// 查重失败(DB 异常)时退化为随机后缀,避免与既有记录冲突
|
||||||
|
rand, rerr := auth.NewToken()
|
||||||
|
if rerr != nil {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
return base + "-" + rand[:6]
|
||||||
|
}
|
||||||
|
if !taken {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
candidate = fmt.Sprintf("%s-%d", base, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,562 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pure-note/internal/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- 认证会话组(§13)----
|
||||||
|
|
||||||
|
func TestLoginFlow(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
|
||||||
|
// 匿名 /api/me
|
||||||
|
c := e.client()
|
||||||
|
resp, body := e.get(c, "/api/me")
|
||||||
|
if resp.StatusCode != 200 || strings.Contains(string(body), `"authenticated":true`) {
|
||||||
|
t.Errorf("匿名 /api/me 应为 authenticated:false: %s", body)
|
||||||
|
}
|
||||||
|
// 错误口令:统一 401 文案
|
||||||
|
resp, body = e.do(c, http.MethodPost, "/api/auth/login", []byte(`{"password":"wrong"}`), nil)
|
||||||
|
if resp.StatusCode != http.StatusUnauthorized {
|
||||||
|
t.Errorf("错误口令应 401,实际 %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(body), "用户名或密码错误") {
|
||||||
|
t.Errorf("统一文案缺失: %s", body)
|
||||||
|
}
|
||||||
|
// 空口令同样 401
|
||||||
|
resp, _ = e.do(c, http.MethodPost, "/api/auth/login", []byte(`{"password":""}`), nil)
|
||||||
|
if resp.StatusCode != http.StatusUnauthorized {
|
||||||
|
t.Errorf("空口令应 401,实际 %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
// 正确口令
|
||||||
|
resp, body = e.do(c, http.MethodPost, "/api/auth/login", []byte(fmt.Sprintf(`{"password":%q}`, testPassword)), nil)
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
t.Fatalf("登录失败: %d %s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
var out struct {
|
||||||
|
Data struct {
|
||||||
|
CSRFToken string `json:"csrf_token"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(body, &out)
|
||||||
|
if out.Data.CSRFToken == "" {
|
||||||
|
t.Fatal("登录响应应含 csrf_token")
|
||||||
|
}
|
||||||
|
// 认证态 /api/me
|
||||||
|
resp, body = e.get(c, "/api/me")
|
||||||
|
if !strings.Contains(string(body), `"authenticated":true`) || !strings.Contains(string(body), out.Data.CSRFToken) {
|
||||||
|
t.Errorf("认证态 /api/me 应含同一 csrf_token: %s", body)
|
||||||
|
}
|
||||||
|
// 登出
|
||||||
|
resp, _ = e.do(c, http.MethodPost, "/api/auth/logout", nil, nil)
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
t.Errorf("登出应 200: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
// 登出后会话失效
|
||||||
|
resp, body = e.get(c, "/api/me")
|
||||||
|
if strings.Contains(string(body), `"authenticated":true`) {
|
||||||
|
t.Errorf("登出后 /api/me 应为匿名: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginRateLimit(t *testing.T) {
|
||||||
|
// 真实限流参数:per-IP 10/5min,per-账号 5/10min
|
||||||
|
e := newEnvRealLimits(t)
|
||||||
|
c := e.client()
|
||||||
|
// 前 5 次失败:401
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
resp, _ := e.do(c, http.MethodPost, "/api/auth/login", []byte(`{"password":"wrong"}`), nil)
|
||||||
|
if resp.StatusCode != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("第 %d 次失败应 401,实际 %d", i+1, resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 第 6 次:账号桶耗尽 → 429 + Retry-After
|
||||||
|
resp, body := e.do(c, http.MethodPost, "/api/auth/login", []byte(`{"password":"wrong"}`), nil)
|
||||||
|
if resp.StatusCode != http.StatusTooManyRequests {
|
||||||
|
t.Fatalf("第 6 次应 429,实际 %d %s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
if resp.Header.Get("Retry-After") == "" {
|
||||||
|
t.Error("429 应带 Retry-After")
|
||||||
|
}
|
||||||
|
// 正确口令也被 429(桶已空)
|
||||||
|
resp, _ = e.do(c, http.MethodPost, "/api/auth/login", []byte(fmt.Sprintf(`{"password":%q}`, testPassword)), nil)
|
||||||
|
if resp.StatusCode != http.StatusTooManyRequests {
|
||||||
|
t.Errorf("桶空时正确口令也应 429,实际 %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
// 成功不计费:1 次失败后成功仍可
|
||||||
|
e2 := newEnvRealLimits(t)
|
||||||
|
c2 := e2.client()
|
||||||
|
_, _ = e2.do(c2, http.MethodPost, "/api/auth/login", []byte(`{"password":"wrong"}`), nil)
|
||||||
|
resp, _ = e2.do(c2, http.MethodPost, "/api/auth/login", []byte(fmt.Sprintf(`{"password":%q}`, testPassword)), nil)
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
t.Errorf("失败 1 次后正确口令应可登录: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newEnvRealLimits(t *testing.T) *testEnv {
|
||||||
|
t.Helper()
|
||||||
|
return newEnvCustom(t,
|
||||||
|
nil, // 全局用高容量桶(隔离测试)
|
||||||
|
middleware.NewLimiter(10.0/(5*60), 10, 4096, 10*time.Minute),
|
||||||
|
middleware.NewLimiter(5.0/(10*60), 5, 16, 10*time.Minute),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPasswordChange(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
admin := e.loginAdmin()
|
||||||
|
|
||||||
|
// 旧密码错误
|
||||||
|
resp, body := e.do(admin, http.MethodPost, "/api/admin/password",
|
||||||
|
[]byte(`{"old_password":"wrong","new_password":"new-password-456"}`), e.adminHeaders())
|
||||||
|
if resp.StatusCode != http.StatusUnauthorized {
|
||||||
|
t.Errorf("旧密码错误应 401: %d %s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
// 新密码太弱
|
||||||
|
resp, _ = e.do(admin, http.MethodPost, "/api/admin/password",
|
||||||
|
[]byte(fmt.Sprintf(`{"old_password":%q,"new_password":"short"}`, testPassword)), e.adminHeaders())
|
||||||
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("弱口令应 400: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
// 成功
|
||||||
|
resp, _ = e.do(admin, http.MethodPost, "/api/admin/password",
|
||||||
|
[]byte(fmt.Sprintf(`{"old_password":%q,"new_password":"new-password-456"}`, testPassword)), e.adminHeaders())
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
t.Fatalf("改密应成功: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
// 旧口令不能再登录
|
||||||
|
c := e.client()
|
||||||
|
resp, _ = e.do(c, http.MethodPost, "/api/auth/login", []byte(fmt.Sprintf(`{"password":%q}`, testPassword)), nil)
|
||||||
|
if resp.StatusCode != http.StatusUnauthorized {
|
||||||
|
t.Errorf("旧口令应失效: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
// 新口令可登录;当前会话不失效(§7.3-7)
|
||||||
|
resp, body = e.get(admin, "/api/me")
|
||||||
|
if !strings.Contains(string(body), `"authenticated":true`) {
|
||||||
|
t.Errorf("改密不应失效当前会话: %s", body)
|
||||||
|
}
|
||||||
|
c2 := e.client()
|
||||||
|
resp, _ = e.do(c2, http.MethodPost, "/api/auth/login", []byte(`{"password":"new-password-456"}`), nil)
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
t.Errorf("新口令应可登录: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionRotation(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
c := e.client()
|
||||||
|
// 直接构造一个 2 天后过期的会话(< 3 天阈值)
|
||||||
|
token := "tok-" + fmt.Sprint(time.Now().UnixNano())
|
||||||
|
csrf := "csrf-" + fmt.Sprint(time.Now().UnixNano())
|
||||||
|
now := time.Now().Unix()
|
||||||
|
if err := e.st.CreateSession(hashToken(token), csrf, now, now+2*86400); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, e.ts.URL+"/api/me", nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: "pn_session", Value: token})
|
||||||
|
resp, err := c.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
// 响应应 Set-Cookie 轮换新 token
|
||||||
|
var newCookie *http.Cookie
|
||||||
|
for _, ck := range resp.Cookies() {
|
||||||
|
if ck.Name == "pn_session" && ck.Value != "" {
|
||||||
|
newCookie = ck
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if newCookie == nil || newCookie.Value == token {
|
||||||
|
t.Fatal("距过期 <3 天应轮换 Cookie token")
|
||||||
|
}
|
||||||
|
// csrf 保持不变(前端无感,§7.3-5)
|
||||||
|
b := new(bytes.Buffer)
|
||||||
|
_, _ = b.ReadFrom(resp.Body)
|
||||||
|
if !strings.Contains(b.String(), csrf) {
|
||||||
|
t.Errorf("轮换后 csrf_token 应不变: %s", b.String())
|
||||||
|
}
|
||||||
|
// 旧 token 行删除、新 token 行存在
|
||||||
|
if _, err := e.st.GetSession(hashToken(token)); err == nil {
|
||||||
|
t.Error("旧 token 应已删除")
|
||||||
|
}
|
||||||
|
sess, err := e.st.GetSession(hashToken(newCookie.Value))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("新 token 应存在: %v", err)
|
||||||
|
}
|
||||||
|
if sess.CSRFToken != csrf {
|
||||||
|
t.Error("新行 csrf 应保持不变")
|
||||||
|
}
|
||||||
|
// 长期有效会话不轮换
|
||||||
|
e2 := newEnv(t)
|
||||||
|
a := e2.loginAdmin()
|
||||||
|
resp, _ = e2.get(a, "/api/admin/notes")
|
||||||
|
if len(resp.Cookies()) > 0 {
|
||||||
|
t.Errorf("距过期 >3 天不应轮换")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- CSRF 组(§13)----
|
||||||
|
|
||||||
|
func TestCSRF(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
admin := e.loginAdmin()
|
||||||
|
|
||||||
|
// 无 token 403
|
||||||
|
resp, _ := e.do(admin, http.MethodPost, "/api/admin/notes",
|
||||||
|
[]byte(`{"title":"x","status":"private"}`), map[string]string{"Content-Type": "application/json"})
|
||||||
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("无 CSRF token 应 403: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
// 错 token 403
|
||||||
|
bad := map[string]string{"X-CSRF-Token": "bad", "Content-Type": "application/json"}
|
||||||
|
resp, _ = e.do(admin, http.MethodPost, "/api/admin/notes", []byte(`{"title":"x","status":"private"}`), bad)
|
||||||
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("错误 CSRF token 应 403: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
// 跨源 Origin 拒绝(admin POST)
|
||||||
|
evil := map[string]string{
|
||||||
|
"Origin": "http://evil.example", "X-CSRF-Token": e.csrf, "Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
resp, _ = e.do(admin, http.MethodPost, "/api/admin/notes", []byte(`{"title":"x","status":"private"}`), evil)
|
||||||
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("跨源 Origin 应 403: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
// 跨源 login 拒绝
|
||||||
|
c := e.client()
|
||||||
|
resp, _ = e.do(c, http.MethodPost, "/api/auth/login", []byte(`{"password":"x"}`),
|
||||||
|
map[string]string{"Origin": "http://evil.example"})
|
||||||
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("跨源 login 应 403: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
// 跨源 logout 拒绝
|
||||||
|
resp, _ = e.do(admin, http.MethodPost, "/api/auth/logout", nil,
|
||||||
|
map[string]string{"Origin": "http://evil.example"})
|
||||||
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("跨源 logout 应 403: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
// 缺 Origin 且缺 Referer 的变更请求拒绝
|
||||||
|
c2 := e.client()
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, e.ts.URL+"/api/auth/login",
|
||||||
|
strings.NewReader(`{"password":"x"}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp2, err := c2.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
resp2.Body.Close()
|
||||||
|
if resp2.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("无 Origin/Referer 应 403: %d", resp2.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 设置白名单组(§9.3)----
|
||||||
|
|
||||||
|
func TestSettingsWhitelist(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
admin := e.loginAdmin()
|
||||||
|
|
||||||
|
// GET 不含 admin_password_hash
|
||||||
|
resp, body := e.get(admin, "/api/admin/settings")
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
t.Fatalf("settings GET: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(body), "password") || strings.Contains(string(body), "argon2") {
|
||||||
|
t.Errorf("settings 响应不得含口令哈希: %s", body)
|
||||||
|
}
|
||||||
|
// 匿名 /api/site 同样不含
|
||||||
|
_, body = e.get(e.client(), "/api/site")
|
||||||
|
if strings.Contains(string(body), "password") || strings.Contains(string(body), "argon2") {
|
||||||
|
t.Errorf("/api/site 不得含口令哈希: %s", body)
|
||||||
|
}
|
||||||
|
// PUT 未知键拒绝
|
||||||
|
resp, _ = e.do(admin, http.MethodPut, "/api/admin/settings",
|
||||||
|
[]byte(`{"admin_password_hash":"evil"}`), e.adminHeaders())
|
||||||
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("未知键应 400: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
// PUT 合法键
|
||||||
|
resp, _ = e.do(admin, http.MethodPut, "/api/admin/settings",
|
||||||
|
[]byte(`{"site_title":"新标题","site_desc":"描述","page_size":5}`), e.adminHeaders())
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
t.Errorf("合法 PUT 应 200: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
// page_size 生效为公开列表默认
|
||||||
|
_, body = e.get(e.client(), "/api/notes")
|
||||||
|
if !strings.Contains(string(body), `"page_size":5`) {
|
||||||
|
t.Errorf("page_size 设置应生效: %s", body)
|
||||||
|
}
|
||||||
|
// page_size 越界
|
||||||
|
resp, _ = e.do(admin, http.MethodPut, "/api/admin/settings",
|
||||||
|
[]byte(`{"page_size":0}`), e.adminHeaders())
|
||||||
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("page_size=0 应 400: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- slug 策略组(§13)----
|
||||||
|
|
||||||
|
func TestSlugPolicy(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
admin := e.loginAdmin()
|
||||||
|
|
||||||
|
// 纯中文标题 → post-YYYYMMDD
|
||||||
|
cnDate := time.Now().Format("20060102")
|
||||||
|
n1 := e.createNote(admin, "", "中文标题一", "内容", "public", nil)
|
||||||
|
if n1["slug"] != "post-"+cnDate {
|
||||||
|
t.Errorf("中文标题应生成 post-%s,实际 %v", cnDate, n1["slug"])
|
||||||
|
}
|
||||||
|
// 同日冲突 → 自动后缀 -2
|
||||||
|
n2 := e.createNote(admin, "", "另一篇中文", "内容", "public", nil)
|
||||||
|
if n2["slug"] != "post-"+cnDate+"-2" {
|
||||||
|
t.Errorf("同日冲突应后缀 -2,实际 %v", n2["slug"])
|
||||||
|
}
|
||||||
|
// ASCII 标题 → 小写连字符
|
||||||
|
n3 := e.createNote(admin, "", "Hello World 42", "内容", "public", nil)
|
||||||
|
if n3["slug"] != "hello-world-42" {
|
||||||
|
t.Errorf("ASCII 标题应 slugify,实际 %v", n3["slug"])
|
||||||
|
}
|
||||||
|
// 与回收站冲突:软删除后同标题仍被占用
|
||||||
|
id := int64(n3["id"].(float64))
|
||||||
|
if err := e.st.SoftDeleteNote(id, time.Now().Unix()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
n4 := e.createNote(admin, "", "Hello World 42?", "内容", "public", nil)
|
||||||
|
if n4["slug"] != "hello-world-42-2" {
|
||||||
|
t.Errorf("回收站占用应自解冲突,实际 %v", n4["slug"])
|
||||||
|
}
|
||||||
|
// 手改冲突 → 409 + 字段级错误
|
||||||
|
resp, body := e.do(admin, http.MethodPut, fmt.Sprintf("/api/admin/notes/%d", int64(n4["id"].(float64))),
|
||||||
|
[]byte(fmt.Sprintf(`{"title":"Hello World 42?","slug":%q,"status":"public"}`, "post-"+cnDate)),
|
||||||
|
e.adminHeaders())
|
||||||
|
if resp.StatusCode != http.StatusConflict {
|
||||||
|
t.Errorf("手改冲突应 409: %d %s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
var errOut struct {
|
||||||
|
Error struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Field string `json:"field"`
|
||||||
|
} `json:"error"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(body, &errOut)
|
||||||
|
if errOut.Error.Code != "slug_conflict" || errOut.Error.Field != "slug" {
|
||||||
|
t.Errorf("应返回 slug 字段级错误: %s", body)
|
||||||
|
}
|
||||||
|
// 非法 slug 格式
|
||||||
|
resp, _ = e.do(admin, http.MethodPut, fmt.Sprintf("/api/admin/notes/%d", int64(n4["id"].(float64))),
|
||||||
|
[]byte(`{"title":"x","slug":"Bad Slug!","status":"public"}`), e.adminHeaders())
|
||||||
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("非法 slug 应 400: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 回收站 + gc 组(§13)----
|
||||||
|
|
||||||
|
func TestTrashLifecycleAndGC(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
admin := e.loginAdmin()
|
||||||
|
anon := e.client()
|
||||||
|
|
||||||
|
imgID := e.uploadPNG(admin, uniquePNG('g'))
|
||||||
|
note := e.createNote(admin, "", "生命周期", "引用 +")", "public", nil)
|
||||||
|
id := int64(note["id"].(float64))
|
||||||
|
slug := note["slug"].(string)
|
||||||
|
|
||||||
|
// 删除 → 公开列表/详情不可见
|
||||||
|
resp, _ := e.do(admin, http.MethodDelete, fmt.Sprintf("/api/admin/notes/%d", id), nil, e.adminHeaders())
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
t.Fatalf("软删除失败: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
_, body := e.get(anon, "/api/notes")
|
||||||
|
if strings.Contains(string(body), slug) {
|
||||||
|
t.Error("回收站笔记不应出现在公开列表")
|
||||||
|
}
|
||||||
|
resp, _ = e.get(anon, "/api/notes/"+slug)
|
||||||
|
if resp.StatusCode != 404 {
|
||||||
|
t.Errorf("回收站详情应 404: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
// 图片同样退出公开(并集语义失效)
|
||||||
|
resp, _ = e.get(anon, fmt.Sprintf("/api/images/%d", imgID))
|
||||||
|
if resp.StatusCode != 404 {
|
||||||
|
t.Errorf("回收站引用图片应 404: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
// 恢复 → 可见,图片引用天然保全
|
||||||
|
resp, _ = e.do(admin, http.MethodPost, fmt.Sprintf("/api/admin/trash/%d/restore", id), nil, e.adminHeaders())
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
t.Fatalf("恢复失败: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
resp, _ = e.get(anon, "/api/notes/"+slug)
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
t.Errorf("恢复后应可见: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
resp, _ = e.get(anon, fmt.Sprintf("/api/images/%d", imgID))
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
t.Errorf("恢复后图片应匿名可见: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// gc:30 天过期物理删除 + 7 天宽限孤儿图片 + 过期会话
|
||||||
|
// 7 天内的孤儿图片不被删:先造一张新孤儿图(趁会话有效)
|
||||||
|
freshImg := e.uploadPNG(admin, uniquePNG('h'))
|
||||||
|
if _, err := e.st.DB().Exec(`DELETE FROM image_refs WHERE image_id=?`, freshImg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
old := time.Now().Add(-31 * 24 * time.Hour).Unix()
|
||||||
|
if _, err := e.st.DB().Exec(`UPDATE notes SET deleted_at=? WHERE id=?`, old, id); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := e.st.DB().Exec(`UPDATE images SET created_at=? WHERE id=?`, old, imgID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// 把管理员会话改为过期
|
||||||
|
if _, err := e.st.DB().Exec(`UPDATE sessions SET expires_at=?`, time.Now().Add(-time.Hour).Unix()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dry-run 不改数据
|
||||||
|
rep, err := e.st.GC(time.Now(), true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(rep.ExpiredNotes) != 1 || rep.ExpiredSessons < 1 {
|
||||||
|
t.Errorf("dry-run 应报告 1 篇过期笔记与过期会话: %+v", rep)
|
||||||
|
}
|
||||||
|
if _, err := e.st.GetNoteByID(id); err != nil {
|
||||||
|
t.Error("dry-run 不得删除数据")
|
||||||
|
}
|
||||||
|
// commit 执行
|
||||||
|
rep, err = e.st.GC(time.Now(), false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !rep.Committed || len(rep.ExpiredNotes) != 1 {
|
||||||
|
t.Errorf("commit 应物理删除: %+v", rep)
|
||||||
|
}
|
||||||
|
if _, err := e.st.GetNoteByID(id); err == nil {
|
||||||
|
t.Error("过期笔记应被物理删除")
|
||||||
|
}
|
||||||
|
if _, err := e.st.GetImageMeta(imgID); err != nil {
|
||||||
|
t.Error("超宽限期的孤儿图片应被删除")
|
||||||
|
}
|
||||||
|
if _, err := e.st.GetImageMeta(freshImg); err != nil {
|
||||||
|
t.Error("7 天内的孤儿图片不应被删除")
|
||||||
|
}
|
||||||
|
sessLeft, err := e.st.DeleteExpiredSessions(time.Now().Unix())
|
||||||
|
_ = sessLeft
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// 过期会话已被 gc 清空:登录表里应无行
|
||||||
|
var cnt int
|
||||||
|
if err := e.st.DB().QueryRow(`SELECT COUNT(*) FROM sessions`).Scan(&cnt); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cnt != 0 {
|
||||||
|
t.Errorf("过期会话应被 gc 清除,剩余 %d", cnt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 上传组(§13)----
|
||||||
|
|
||||||
|
func TestUploadValidation(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
admin := e.loginAdmin()
|
||||||
|
|
||||||
|
upload := func(fileName, contentType string, data []byte) (int, string) {
|
||||||
|
var body bytes.Buffer
|
||||||
|
boundary := "bnd"
|
||||||
|
body.WriteString("--" + boundary + "\r\n")
|
||||||
|
body.WriteString(`Content-Disposition: form-data; name="file"; filename="` + fileName + `"` + "\r\n")
|
||||||
|
body.WriteString("Content-Type: " + contentType + "\r\n\r\n")
|
||||||
|
body.Write(data)
|
||||||
|
body.WriteString("\r\n--" + boundary + "--\r\n")
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, e.ts.URL+"/api/admin/images", &body)
|
||||||
|
req.Header.Set("Content-Type", "multipart/form-data; boundary="+boundary)
|
||||||
|
req.Header.Set("Origin", e.ts.URL)
|
||||||
|
req.Header.Set("X-CSRF-Token", e.csrf)
|
||||||
|
resp, err := admin.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var out bytes.Buffer
|
||||||
|
_, _ = out.ReadFrom(resp.Body)
|
||||||
|
return resp.StatusCode, out.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 魔数不符(文本伪装 png)
|
||||||
|
code, body := upload("fake.png", "image/png", []byte("hello this is not an image"))
|
||||||
|
if code != http.StatusUnsupportedMediaType {
|
||||||
|
t.Errorf("魔数不符应 415: %d %s", code, body)
|
||||||
|
}
|
||||||
|
// SVG 一律拒绝
|
||||||
|
svg := []byte(`<svg xmlns="http://www.w3.org/2000/svg"/>`)
|
||||||
|
code, _ = upload("x.svg", "image/svg+xml", svg)
|
||||||
|
if code != http.StatusUnsupportedMediaType {
|
||||||
|
t.Errorf("SVG 应拒绝: %d", code)
|
||||||
|
}
|
||||||
|
// 声明类型白名单外
|
||||||
|
code, _ = upload("x.png", "application/octet-stream", png1x1)
|
||||||
|
if code != http.StatusUnsupportedMediaType {
|
||||||
|
t.Errorf("声明类型白名单外应拒绝: %d", code)
|
||||||
|
}
|
||||||
|
// 空文件
|
||||||
|
code, _ = upload("x.png", "image/png", nil)
|
||||||
|
if code != http.StatusBadRequest {
|
||||||
|
t.Errorf("空文件应 400: %d", code)
|
||||||
|
}
|
||||||
|
// 超 5MB
|
||||||
|
big := make([]byte, 5<<20+1)
|
||||||
|
copy(big, png1x1)
|
||||||
|
code, _ = upload("big.png", "image/png", big)
|
||||||
|
if code != http.StatusRequestEntityTooLarge {
|
||||||
|
t.Errorf("超限应 413: %d", code)
|
||||||
|
}
|
||||||
|
// 合法 PNG → 201
|
||||||
|
code, body = upload("ok.png", "image/png", uniquePNG('v'))
|
||||||
|
if code != http.StatusCreated {
|
||||||
|
t.Fatalf("合法 PNG 应 201: %d %s", code, body)
|
||||||
|
}
|
||||||
|
var out struct {
|
||||||
|
Data struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal([]byte(body), &out)
|
||||||
|
if out.Data.URL != fmt.Sprintf("/api/images/%d", out.Data.ID) {
|
||||||
|
t.Errorf("响应应含 id 与 url: %s", body)
|
||||||
|
}
|
||||||
|
// 去重命中:同字节再传同 id
|
||||||
|
code, body = upload("ok2.png", "image/png", uniquePNG('v'))
|
||||||
|
_ = json.Unmarshal([]byte(body), &out)
|
||||||
|
if code != http.StatusCreated || out.Data.ID == 0 {
|
||||||
|
t.Fatalf("重复上传应 201: %d %s", code, body)
|
||||||
|
}
|
||||||
|
var first struct {
|
||||||
|
Data struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal([]byte(fmt.Sprintf(`{"data":{"id":%d}}`, mustImageID(t, e, uniquePNG('v')))), &first)
|
||||||
|
// 直接查库确认只有一行
|
||||||
|
var cnt int
|
||||||
|
if err := e.st.DB().QueryRow(`SELECT COUNT(*) FROM images WHERE size=?`, len(uniquePNG('v'))).Scan(&cnt); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cnt != 1 {
|
||||||
|
t.Errorf("同字节图片应去重为一行,实际 %d 行", cnt)
|
||||||
|
}
|
||||||
|
_ = first
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustImageID(t *testing.T, e *testEnv, png []byte) int64 {
|
||||||
|
t.Helper()
|
||||||
|
return e.uploadPNG(e.loginAdmin(), png)
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pure-note/internal/auth"
|
||||||
|
"pure-note/internal/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
type loginRequest struct {
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleLogin POST /api/auth/login。
|
||||||
|
// Origin 校验由全局中间件完成(含 login,§9.1-T2);此处做防爆破与口令校验。
|
||||||
|
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req loginRequest
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "bad_request", "请求体不是合法 JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ip := middleware.ClientIP(r, s.cfg.BehindProxy)
|
||||||
|
const account = "admin" // 单管理员账号维度
|
||||||
|
|
||||||
|
// 预检:桶已耗尽直接 429(避免无谓的 Argon2 计算),429 + Retry-After(§7.2)
|
||||||
|
if !s.loginIP.Available(ip) || !s.loginAcct.Available(account) {
|
||||||
|
retry := max(s.loginIP.RetryAfter(ip), s.loginAcct.RetryAfter(account))
|
||||||
|
w.Header().Set("Retry-After", strconv.Itoa(retry))
|
||||||
|
writeError(w, http.StatusTooManyRequests, "rate_limited", "尝试过于频繁,请稍后再试")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, ok, err := s.st.GetSetting("admin_password_hash")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusInternalServerError, "not_initialized", "尚未初始化管理员口令,请先执行 pure-note init")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Password == "" || !auth.VerifyPassword(hash, req.Password) {
|
||||||
|
// 失败才计费:消费两维度令牌(fail-only,§7.3-2)
|
||||||
|
s.loginIP.Allow(ip)
|
||||||
|
s.loginAcct.Allow(account)
|
||||||
|
// 记录 IP 与桶剩余计数(§7.2/§10.5)
|
||||||
|
s.log.Warn("login_failed",
|
||||||
|
"ip", ip,
|
||||||
|
"ip_bucket_left", s.loginIP.Remaining(ip),
|
||||||
|
"account_bucket_left", s.loginAcct.Remaining(account))
|
||||||
|
// 统一 401 文案,不泄露差异(§7.3-2)
|
||||||
|
writeError(w, http.StatusUnauthorized, "invalid_credentials", "用户名或密码错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登录成功:重建会话行(防会话固定,§7.3-3)
|
||||||
|
token, err := newToken()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
csrf, err := newToken()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now().Unix()
|
||||||
|
if err := s.st.CreateSession(hashToken(token), csrf, now, now+int64(sessionTTL.Seconds())); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.SetCookie(w, s.sessionCookie(token, int(sessionTTL.Seconds())))
|
||||||
|
s.log.Info("admin_action", "op", "login", "ip", ip)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"csrf_token": csrf})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleLogout POST /api/auth/logout:删除会话行 + 清 Cookie。
|
||||||
|
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if c, err := r.Cookie(s.cookieName()); err == nil && c.Value != "" {
|
||||||
|
if err := s.st.DeleteSession(hashToken(c.Value)); err != nil && !errors.Is(err, nil) {
|
||||||
|
// 删除失败不阻断登出(幂等)
|
||||||
|
s.log.Error("删除会话失败", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
http.SetCookie(w, s.sessionCookie("", -1))
|
||||||
|
s.log.Info("admin_action", "op", "logout")
|
||||||
|
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"pure-note/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newToken() (string, error) { return auth.NewToken() }
|
||||||
|
|
||||||
|
func hashToken(token string) string { return auth.HashToken(token) }
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"pure-note/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleHealth GET /api/health。
|
||||||
|
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := s.st.DB().Ping(); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "db_unavailable", "数据库不可用")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMe GET /api/me:匿名 {authenticated:false};已认证 {authenticated:true, csrf_token}。
|
||||||
|
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if sess, ok := s.sessionFrom(r); ok {
|
||||||
|
s.maybeRotate(w, sess)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"authenticated": true, "csrf_token": sess.CSRFToken})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"authenticated": false})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSiteInfo GET /api/site:站点设置白名单视图(标题/副标题/每页条数)。
|
||||||
|
// 永不包含 admin_password_hash(§9.3)。
|
||||||
|
func (s *Server) handleSiteInfo(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ss, err := s.st.GetSiteSettings()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, ss)
|
||||||
|
}
|
||||||
|
|
||||||
|
// publicNoteItem 公开列表项(元信息,不含全文)。
|
||||||
|
type publicNoteItem struct {
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
Pinned bool `json:"pinned"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePublicNotes GET /api/notes:公开笔记列表(可见性过滤在查询层,§9.1-T10)。
|
||||||
|
func (s *Server) handlePublicNotes(w http.ResponseWriter, r *http.Request) {
|
||||||
|
page, pageSize, ok := s.parsePagination(r)
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusBadRequest, "bad_request", "分页参数越界(page ∈ [1,10000],page_size ∈ [1,100])")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tag := r.URL.Query().Get("tag")
|
||||||
|
notes, total, err := s.st.ListPublicNotes(page, pageSize, tag)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("查询公开列表失败", "err", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items := make([]publicNoteItem, 0, len(notes))
|
||||||
|
for _, n := range notes {
|
||||||
|
items = append(items, publicNoteItem{
|
||||||
|
Slug: n.Slug, Title: n.Title, Summary: n.Summary,
|
||||||
|
Tags: n.Tags, Pinned: n.Pinned,
|
||||||
|
CreatedAt: n.CreatedAt, UpdatedAt: n.UpdatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"items": items, "page": page, "page_size": pageSize, "total": total,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePublicNote GET /api/notes/{slug}:public 或管理员会话可读;其余统一 404。
|
||||||
|
func (s *Server) handlePublicNote(w http.ResponseWriter, r *http.Request) {
|
||||||
|
slug := r.PathValue("slug")
|
||||||
|
note, err := s.st.GetNoteBySlug(slug)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "笔记不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if note.DeletedAt != nil {
|
||||||
|
// 回收站内容仅经 /api/admin/trash 出口可见(§13)
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "笔记不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if note.Status != "public" {
|
||||||
|
if _, isAdmin := s.sessionFrom(r); !isAdmin {
|
||||||
|
// 私有笔记对匿名统一 404(不泄露存在性);管理员可私有预览
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "笔记不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prevSlug, prevTitle, _ := s.st.AdjacentPublicNote(note, "prev")
|
||||||
|
nextSlug, nextTitle, _ := s.st.AdjacentPublicNote(note, "next")
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"id": note.ID, "slug": note.Slug, "title": note.Title,
|
||||||
|
"summary": note.Summary, "content": note.Content,
|
||||||
|
"status": note.Status, "tags": note.Tags, "pinned": note.Pinned,
|
||||||
|
"created_at": note.CreatedAt, "updated_at": note.UpdatedAt,
|
||||||
|
"prev": siblingOrEmpty(prevSlug, prevTitle),
|
||||||
|
"next": siblingOrEmpty(nextSlug, nextTitle),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func siblingOrEmpty(slug, title string) map[string]any {
|
||||||
|
if slug == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return map[string]any{"slug": slug, "title": title}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleTags GET /api/tags:标签聚合(仅公开且未删除)。
|
||||||
|
func (s *Server) handleTags(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tags, err := s.st.PublicTags()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if tags == nil {
|
||||||
|
tags = []store.TagCount{}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"tags": tags})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleImage GET /api/images/{id}:并集可见性;未授权与不存在统一 404;
|
||||||
|
// 缓存头按可见性分流(§7.4)。
|
||||||
|
func (s *Server) handleImage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
idStr := r.PathValue("id")
|
||||||
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||||
|
if err != nil || id <= 0 {
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "图片不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
img, err := s.st.GetImageMeta(id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "图片不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
public, err := s.st.ImageIsPublic(id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !public {
|
||||||
|
if _, isAdmin := s.sessionFrom(r); !isAdmin {
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "图片不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 非公开图:每次请求重新判定,禁止缓存
|
||||||
|
w.Header().Set("Cache-Control", "private, no-store")
|
||||||
|
} else {
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", img.MIME)
|
||||||
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
w.Header().Set("ETag", `"`+img.SHA256+`"`)
|
||||||
|
if r.Header.Get("If-None-Match") == `"`+img.SHA256+`"` {
|
||||||
|
w.WriteHeader(http.StatusNotModified)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := s.st.GetImageData(id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "图片不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
|
||||||
|
_, _ = w.Write(data)
|
||||||
|
}
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
// Package httpapi 路由注册与全部 HTTP handler。
|
||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pure-note/internal/config"
|
||||||
|
"pure-note/internal/middleware"
|
||||||
|
"pure-note/internal/store"
|
||||||
|
"pure-note/internal/webui"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 会话与安全参数(§7.3)。
|
||||||
|
const (
|
||||||
|
sessionTTL = 7 * 24 * time.Hour // 会话有效期
|
||||||
|
rotateThreshold = 3 * 24 * time.Hour // 距过期 < 3 天时滑动轮换
|
||||||
|
cookieName = "__Host-pn_session" // 生产 Cookie(__Host- 前缀)
|
||||||
|
devCookieName = "pn_session" // 开发模式 Cookie(非 Secure)
|
||||||
|
maxAuthBody = 64 << 10 // auth/settings 请求体上限 64KB
|
||||||
|
maxNoteBody = 1 << 20 // notes JSON 上限 1MB
|
||||||
|
maxUploadBody = 6 << 20 // multipart 上限 6MB(含边界开销)
|
||||||
|
maxImageSize = 5 << 20 // 单图上限 5MB
|
||||||
|
)
|
||||||
|
|
||||||
|
// Server HTTP 服务。
|
||||||
|
type Server struct {
|
||||||
|
st *store.Store
|
||||||
|
cfg *config.Config
|
||||||
|
log *slog.Logger
|
||||||
|
global *middleware.Limiter // 全局宽松限流(per-IP)
|
||||||
|
loginIP *middleware.Limiter // 登录/改密 per-IP:10 次/5 分钟
|
||||||
|
loginAcct *middleware.Limiter // 登录/改密 per-账号:5 次/10 分钟
|
||||||
|
}
|
||||||
|
|
||||||
|
// New 构造 Server(生产限流参数,§7.2)。
|
||||||
|
func New(st *store.Store, cfg *config.Config, log *slog.Logger) *Server {
|
||||||
|
return NewWithLimiters(st, cfg, log,
|
||||||
|
// 全局宽松令牌桶:20 req/s、burst 40;桶上限 4096 + 10 分钟 TTL 逐出
|
||||||
|
middleware.NewLimiter(20, 40, 4096, 10*time.Minute),
|
||||||
|
// 登录/改密:per-IP 10 次/5 分钟 + per-账号 5 次/10 分钟(fail-only)
|
||||||
|
middleware.NewLimiter(10.0/(5*60), 10, 4096, 10*time.Minute),
|
||||||
|
middleware.NewLimiter(5.0/(10*60), 5, 16, 10*time.Minute),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWithLimiters 测试注入口:替换各维度限流器。
|
||||||
|
func NewWithLimiters(st *store.Store, cfg *config.Config, log *slog.Logger,
|
||||||
|
global, loginIP, loginAcct *middleware.Limiter) *Server {
|
||||||
|
return &Server{
|
||||||
|
st: st,
|
||||||
|
cfg: cfg,
|
||||||
|
log: log,
|
||||||
|
global: global,
|
||||||
|
loginIP: loginIP,
|
||||||
|
loginAcct: loginAcct,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler 组装完整路由与中间件链(§7.2 顺序)。
|
||||||
|
func (s *Server) Handler(ui http.Handler) http.Handler {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
// ---- 公共 API ----
|
||||||
|
mux.HandleFunc("GET /api/health", s.handleHealth)
|
||||||
|
mux.HandleFunc("GET /api/me", s.handleMe)
|
||||||
|
mux.HandleFunc("GET /api/site", s.handleSiteInfo)
|
||||||
|
mux.HandleFunc("GET /api/notes", s.handlePublicNotes)
|
||||||
|
mux.HandleFunc("GET /api/notes/{slug}", s.handlePublicNote)
|
||||||
|
mux.HandleFunc("GET /api/tags", s.handleTags)
|
||||||
|
mux.HandleFunc("GET /api/images/{id}", s.handleImage)
|
||||||
|
|
||||||
|
// ---- 订阅与 SEO ----
|
||||||
|
mux.HandleFunc("GET /feed.xml", s.handleRSS)
|
||||||
|
mux.HandleFunc("GET /sitemap.xml", s.handleSitemap)
|
||||||
|
mux.HandleFunc("GET /robots.txt", s.handleRobots)
|
||||||
|
|
||||||
|
// ---- 认证(Origin 校验全量覆盖非 GET,含 /api/auth/*)----
|
||||||
|
authMux := http.NewServeMux()
|
||||||
|
authMux.Handle("POST /api/auth/login", middleware.MaxBytes(maxAuthBody)(http.HandlerFunc(s.handleLogin)))
|
||||||
|
authMux.Handle("POST /api/auth/logout", middleware.MaxBytes(maxAuthBody)(http.HandlerFunc(s.handleLogout)))
|
||||||
|
mux.Handle("/api/auth/", middleware.NoStore(authMux))
|
||||||
|
|
||||||
|
// ---- 管理(会话 + CSRF)----
|
||||||
|
adminMux := http.NewServeMux()
|
||||||
|
adminMux.HandleFunc("GET /api/admin/notes", s.handleAdminNotes)
|
||||||
|
adminMux.Handle("POST /api/admin/notes", middleware.MaxBytes(maxNoteBody)(http.HandlerFunc(s.handleAdminNoteCreate)))
|
||||||
|
adminMux.HandleFunc("GET /api/admin/notes/{id}", s.handleAdminNoteGet)
|
||||||
|
adminMux.Handle("PUT /api/admin/notes/{id}", middleware.MaxBytes(maxNoteBody)(http.HandlerFunc(s.handleAdminNoteUpdate)))
|
||||||
|
adminMux.HandleFunc("DELETE /api/admin/notes/{id}", s.handleAdminNoteDelete)
|
||||||
|
adminMux.HandleFunc("GET /api/admin/trash", s.handleAdminTrash)
|
||||||
|
adminMux.HandleFunc("POST /api/admin/trash/{id}/restore", s.handleAdminTrashRestore)
|
||||||
|
adminMux.Handle("POST /api/admin/images", middleware.MaxBytes(maxUploadBody)(http.HandlerFunc(s.handleAdminImageUpload)))
|
||||||
|
adminMux.HandleFunc("GET /api/admin/images", s.handleAdminImages)
|
||||||
|
adminMux.HandleFunc("GET /api/admin/settings", s.handleAdminSettingsGet)
|
||||||
|
adminMux.Handle("PUT /api/admin/settings", middleware.MaxBytes(maxAuthBody)(http.HandlerFunc(s.handleAdminSettingsPut)))
|
||||||
|
adminMux.Handle("POST /api/admin/password", middleware.MaxBytes(maxAuthBody)(http.HandlerFunc(s.handleAdminPassword)))
|
||||||
|
mux.Handle("/api/admin/", s.requireAdmin(adminMux))
|
||||||
|
|
||||||
|
// ---- SPA(兜底,须最后注册)----
|
||||||
|
if ui != nil {
|
||||||
|
mux.Handle("/", ui)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 中间件链:SecurityHeaders → 日志 → 全局限流 → Origin 校验
|
||||||
|
var h http.Handler = mux
|
||||||
|
h = middleware.OriginCheck(h)
|
||||||
|
h = s.globalLimit(h)
|
||||||
|
h = middleware.RequestLogger(s.log)(h)
|
||||||
|
h = middleware.SecurityHeaders(h)
|
||||||
|
h = middleware.BehindProxy(s.cfg.BehindProxy)(h)
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
// globalLimit 全局宽松限流(per-IP),超限 429。
|
||||||
|
func (s *Server) globalLimit(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ip := middleware.ClientIP(r, s.cfg.BehindProxy)
|
||||||
|
if !s.global.Allow(ip) {
|
||||||
|
writeError(w, http.StatusTooManyRequests, "rate_limited", "请求过于频繁")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireAdmin 会话校验(401)→ CSRF 头校验(403,仅变更请求)。
|
||||||
|
func (s *Server) requireAdmin(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sess, ok := s.sessionFrom(r)
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusUnauthorized, "unauthorized", "未登录或会话已过期")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||||
|
token := r.Header.Get("X-CSRF-Token")
|
||||||
|
if token == "" || token != sess.CSRFToken {
|
||||||
|
writeError(w, http.StatusForbidden, "csrf_failed", "CSRF 校验失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 滑动续期:距过期 < 3 天时重建会话行并轮换 Cookie(csrf 不变)
|
||||||
|
s.maybeRotate(w, sess)
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- JSON 包络(§7.1 统一约定)----
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"data": v})
|
||||||
|
}
|
||||||
|
|
||||||
|
// apiError 业务错误(field 非空时为字段级错误提示,§8.2 slug 冲突)。
|
||||||
|
type apiError struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Field string `json:"field,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeError(w http.ResponseWriter, status int, code, msg string) {
|
||||||
|
writeErrorFull(w, status, apiError{Code: code, Message: msg})
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeErrorFull(w http.ResponseWriter, status int, e apiError) {
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"error": e})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 请求/响应小工具 ----
|
||||||
|
|
||||||
|
func decodeJSON(r *http.Request, v any) error {
|
||||||
|
dec := json.NewDecoder(r.Body)
|
||||||
|
return dec.Decode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pathID(r *http.Request) (int64, bool) {
|
||||||
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
|
if err != nil || id <= 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return id, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePagination 解析 page/page_size,越界返回 ok=false(§7.1:400)。
|
||||||
|
func (s *Server) parsePagination(r *http.Request) (page, pageSize int, ok bool) {
|
||||||
|
page = 1
|
||||||
|
pageSize = s.defaultPageSize()
|
||||||
|
if v := r.URL.Query().Get("page"); v != "" {
|
||||||
|
n, err := strconv.Atoi(v)
|
||||||
|
if err != nil || n < 1 || n > 10000 {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
page = n
|
||||||
|
}
|
||||||
|
if v := r.URL.Query().Get("page_size"); v != "" {
|
||||||
|
n, err := strconv.Atoi(v)
|
||||||
|
if err != nil || n < 1 || n > 100 {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
pageSize = n
|
||||||
|
}
|
||||||
|
return page, pageSize, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) defaultPageSize() int {
|
||||||
|
ss, err := s.st.GetSiteSettings()
|
||||||
|
if err != nil {
|
||||||
|
return store.DefaultPageSize
|
||||||
|
}
|
||||||
|
return ss.PageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 会话 ----
|
||||||
|
|
||||||
|
// cookieName 按模式返回。
|
||||||
|
func (s *Server) cookieName() string {
|
||||||
|
if s.cfg.Dev {
|
||||||
|
return devCookieName
|
||||||
|
}
|
||||||
|
return cookieName
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionCookie 构造会话 Cookie(§7.3-3:__Host- 前缀 + Secure + HttpOnly + Lax + Path=/ + 7d)。
|
||||||
|
func (s *Server) sessionCookie(value string, maxAge int) *http.Cookie {
|
||||||
|
return &http.Cookie{
|
||||||
|
Name: s.cookieName(),
|
||||||
|
Value: value,
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: maxAge,
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: !s.cfg.Dev,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionFrom 从 Cookie 还原有效会话(过期行视作无会话)。
|
||||||
|
func (s *Server) sessionFrom(r *http.Request) (*store.Session, bool) {
|
||||||
|
c, err := r.Cookie(s.cookieName())
|
||||||
|
if err != nil || c.Value == "" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
sess, err := s.st.GetSession(hashToken(c.Value))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
s.log.Error("查询会话失败", "err", err)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if sess.ExpiresAt <= time.Now().Unix() {
|
||||||
|
_ = s.st.DeleteSession(sess.TokenHash)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return sess, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// maybeRotate 活跃访问距过期 < 3 天时轮换 token(csrf 保持不变,§7.3-5)。
|
||||||
|
func (s *Server) maybeRotate(w http.ResponseWriter, sess *store.Session) {
|
||||||
|
remaining := time.Until(time.Unix(sess.ExpiresAt, 0))
|
||||||
|
if remaining >= rotateThreshold {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
newToken, err := newToken()
|
||||||
|
if err != nil {
|
||||||
|
return // 轮换失败不影响本次请求
|
||||||
|
}
|
||||||
|
now := time.Now().Unix()
|
||||||
|
if err := s.st.RotateSession(sess.TokenHash, hashToken(newToken), sess.CSRFToken, now, now+int64(sessionTTL.Seconds())); err != nil {
|
||||||
|
s.log.Error("轮换会话失败", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.SetCookie(w, s.sessionCookie(newToken, int(sessionTTL.Seconds())))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 元信息注入(§8.3-4,可见性规则)----
|
||||||
|
|
||||||
|
// ResolveMeta 按请求路径为 SPA index.html 计算 meta:
|
||||||
|
// 仅当笔记可见(public,或管理员会话)才注入该笔记 meta,否则回退站点默认。
|
||||||
|
func (s *Server) ResolveMeta(r *http.Request) webui.Meta {
|
||||||
|
ss, err := s.st.GetSiteSettings()
|
||||||
|
if err != nil {
|
||||||
|
ss = &store.SiteSettings{SiteTitle: store.DefaultSiteTitle}
|
||||||
|
}
|
||||||
|
m := webui.Meta{
|
||||||
|
Title: ss.SiteTitle,
|
||||||
|
Description: ss.SiteDesc,
|
||||||
|
OGTitle: ss.SiteTitle,
|
||||||
|
OGDescription: ss.SiteDesc,
|
||||||
|
OGType: "website",
|
||||||
|
SiteName: ss.SiteTitle,
|
||||||
|
OGURL: baseURL(r) + "/",
|
||||||
|
}
|
||||||
|
slug := metaSlug(r.URL.Path)
|
||||||
|
if slug == "" {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
note, err := s.st.GetNoteBySlug(slug)
|
||||||
|
if err != nil || note.Status != "public" || note.DeletedAt != nil {
|
||||||
|
// 私有/回收站/不存在:统一回退站点默认 meta(不泄露存在性)
|
||||||
|
if note != nil {
|
||||||
|
if _, isAdmin := s.sessionFrom(r); isAdmin {
|
||||||
|
// 管理员预览私有笔记时允许注入真实 meta
|
||||||
|
return s.noteMeta(r, m, note)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
return s.noteMeta(r, m, note)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) noteMeta(r *http.Request, base webui.Meta, n *store.Note) webui.Meta {
|
||||||
|
desc := n.Summary
|
||||||
|
if desc == "" {
|
||||||
|
desc = base.Description
|
||||||
|
}
|
||||||
|
base.Title = n.Title + " - " + base.SiteName
|
||||||
|
base.Description = desc
|
||||||
|
base.OGTitle = n.Title
|
||||||
|
base.OGDescription = desc
|
||||||
|
base.OGType = "article"
|
||||||
|
base.OGURL = baseURL(r) + "/notes/" + n.Slug
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
// metaSlug 从路径提取 /notes/{slug};非该路径返回空。
|
||||||
|
func metaSlug(p string) string {
|
||||||
|
const prefix = "/notes/"
|
||||||
|
if len(p) > len(prefix) && p[:len(prefix)] == prefix {
|
||||||
|
rest := p[len(prefix):]
|
||||||
|
for i := 0; i < len(rest); i++ {
|
||||||
|
if rest[i] == '/' || rest[i] == '?' {
|
||||||
|
return rest[:i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rest
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func baseURL(r *http.Request) string {
|
||||||
|
scheme := "https"
|
||||||
|
if r.Header.Get("X-Forwarded-Proto") != "" {
|
||||||
|
scheme = r.Header.Get("X-Forwarded-Proto")
|
||||||
|
} else if r.TLS == nil {
|
||||||
|
scheme = "http"
|
||||||
|
}
|
||||||
|
return scheme + "://" + r.Host
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
// httpapi 集成测试环境:httptest + 临时目录真实 SQLite(§13)。
|
||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/cookiejar"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"text/template"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pure-note/internal/auth"
|
||||||
|
"pure-note/internal/config"
|
||||||
|
"pure-note/internal/middleware"
|
||||||
|
"pure-note/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
const testPassword = "admin-password-123"
|
||||||
|
|
||||||
|
// testEnv 测试环境。
|
||||||
|
type testEnv struct {
|
||||||
|
t *testing.T
|
||||||
|
st *store.Store
|
||||||
|
srv *Server
|
||||||
|
ts *httptest.Server
|
||||||
|
csrf string // 当前管理员 CSRF token
|
||||||
|
}
|
||||||
|
|
||||||
|
func quietLogger() *slog.Logger {
|
||||||
|
return slog.New(slog.NewTextHandler(newDiscard(), nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func newEnv(t *testing.T) *testEnv {
|
||||||
|
t.Helper()
|
||||||
|
e := newEnvCustom(t, nil, nil, nil)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// newEnvCustom 可注入限流器(nil = 高容量测试桶,避免全局限流干扰)。
|
||||||
|
func newEnvCustom(t *testing.T, global, loginIP, loginAcct *middleware.Limiter) *testEnv {
|
||||||
|
t.Helper()
|
||||||
|
if global == nil {
|
||||||
|
global = middleware.NewLimiter(1e9, 1<<20, 1<<20, time.Minute)
|
||||||
|
}
|
||||||
|
if loginIP == nil {
|
||||||
|
loginIP = middleware.NewLimiter(1e9, 1<<20, 1<<20, time.Minute)
|
||||||
|
}
|
||||||
|
if loginAcct == nil {
|
||||||
|
loginAcct = middleware.NewLimiter(1e9, 1<<20, 1<<20, time.Minute)
|
||||||
|
}
|
||||||
|
dir := t.TempDir()
|
||||||
|
st, err := store.Open(dir+"/pure-note.db", false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("打开测试库失败: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { st.Close() })
|
||||||
|
hash, err := auth.HashPassword(testPassword)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := st.SetSetting(store.KeyAdminPasswordHash, hash); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := st.SetSetting(store.KeySiteTitle, "测试站"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg := &config.Config{Dev: true, DataDir: dir}
|
||||||
|
srv := NewWithLimiters(st, cfg, quietLogger(), global, loginIP, loginAcct)
|
||||||
|
|
||||||
|
// index 模板桩:渲染 ResolveMeta 结果,用于 meta 注入断言
|
||||||
|
tmpl := template.Must(template.New("i").Parse(
|
||||||
|
`<html><head><title>{{.Title}}</title><meta name="description" content="{{.Description}}"><meta property="og:title" content="{{.OGTitle}}"></head><body>SPA</body></html>`))
|
||||||
|
ui := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
m := srv.ResolveMeta(r)
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_ = tmpl.Execute(w, m)
|
||||||
|
})
|
||||||
|
ts := httptest.NewServer(srv.Handler(ui))
|
||||||
|
t.Cleanup(ts.Close)
|
||||||
|
return &testEnv{t: t, st: st, srv: srv, ts: ts}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDiscard() *bytes.Buffer { return &bytes.Buffer{} }
|
||||||
|
|
||||||
|
// client 新建带 Cookie Jar 的客户端。
|
||||||
|
func (e *testEnv) client() *http.Client {
|
||||||
|
jar, _ := cookiejar.New(nil)
|
||||||
|
return &http.Client{Jar: jar}
|
||||||
|
}
|
||||||
|
|
||||||
|
// do 发请求。默认带 Origin(与服务器同源)。
|
||||||
|
func (e *testEnv) do(c *http.Client, method, path string, body []byte, hdr map[string]string) (*http.Response, []byte) {
|
||||||
|
e.t.Helper()
|
||||||
|
var rd *bytes.Reader
|
||||||
|
if body == nil {
|
||||||
|
rd = bytes.NewReader(nil)
|
||||||
|
} else {
|
||||||
|
rd = bytes.NewReader(body)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest(method, e.ts.URL+path, rd)
|
||||||
|
if err != nil {
|
||||||
|
e.t.Fatal(err)
|
||||||
|
}
|
||||||
|
if method != http.MethodGet && method != http.MethodHead {
|
||||||
|
if _, ok := hdr["Origin"]; !ok {
|
||||||
|
req.Header.Set("Origin", e.ts.URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for k, v := range hdr {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
resp, err := c.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
e.t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
_, _ = buf.ReadFrom(resp.Body)
|
||||||
|
return resp, buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *testEnv) get(c *http.Client, path string) (*http.Response, []byte) {
|
||||||
|
return e.do(c, http.MethodGet, path, nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// loginAdmin 登录并保留会话与 CSRF。
|
||||||
|
func (e *testEnv) loginAdmin() *http.Client {
|
||||||
|
e.t.Helper()
|
||||||
|
c := e.client()
|
||||||
|
resp, body := e.do(c, http.MethodPost, "/api/auth/login",
|
||||||
|
[]byte(fmt.Sprintf(`{"password":%q}`, testPassword)), nil)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
e.t.Fatalf("管理员登录失败: %d %s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
var out struct {
|
||||||
|
Data struct {
|
||||||
|
CSRFToken string `json:"csrf_token"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &out); err != nil {
|
||||||
|
e.t.Fatalf("解析登录响应失败: %v", err)
|
||||||
|
}
|
||||||
|
e.csrf = out.Data.CSRFToken
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *testEnv) adminHeaders() map[string]string {
|
||||||
|
e.t.Helper()
|
||||||
|
if e.csrf == "" {
|
||||||
|
e.t.Fatal("尚未登录管理员")
|
||||||
|
}
|
||||||
|
return map[string]string{"X-CSRF-Token": e.csrf, "Content-Type": "application/json"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// createNote 管理员建笔记,返回 Note JSON。
|
||||||
|
func (e *testEnv) createNote(c *http.Client, slug, title, content, status string, tags []string) map[string]any {
|
||||||
|
e.t.Helper()
|
||||||
|
payload := map[string]any{
|
||||||
|
"title": title, "slug": slug, "content": content, "status": status, "tags": tags,
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(payload)
|
||||||
|
resp, body := e.do(c, http.MethodPost, "/api/admin/notes", b, e.adminHeaders())
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
e.t.Fatalf("创建笔记失败(%s): %d %s", title, resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
var out struct {
|
||||||
|
Data map[string]any `json:"data"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(body, &out)
|
||||||
|
return out.Data
|
||||||
|
}
|
||||||
|
|
||||||
|
// uploadPNG 管理员上传一张最小 PNG,返回图片 id。
|
||||||
|
func (e *testEnv) uploadPNG(c *http.Client, png []byte) int64 {
|
||||||
|
e.t.Helper()
|
||||||
|
var body bytes.Buffer
|
||||||
|
boundary := "testboundary123"
|
||||||
|
body.WriteString("--" + boundary + "\r\n")
|
||||||
|
body.WriteString(`Content-Disposition: form-data; name="file"; filename="t.png"` + "\r\n")
|
||||||
|
body.WriteString("Content-Type: image/png\r\n\r\n")
|
||||||
|
body.Write(png)
|
||||||
|
body.WriteString("\r\n--" + boundary + "--\r\n")
|
||||||
|
req, err := http.NewRequest(http.MethodPost, e.ts.URL+"/api/admin/images", &body)
|
||||||
|
if err != nil {
|
||||||
|
e.t.Fatal(err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "multipart/form-data; boundary="+boundary)
|
||||||
|
req.Header.Set("Origin", e.ts.URL)
|
||||||
|
req.Header.Set("X-CSRF-Token", e.csrf)
|
||||||
|
resp, err := c.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
e.t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
_, _ = buf.ReadFrom(resp.Body)
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
e.t.Fatalf("上传图片失败: %d %s", resp.StatusCode, buf.String())
|
||||||
|
}
|
||||||
|
var out struct {
|
||||||
|
Data struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(buf.Bytes(), &out)
|
||||||
|
return out.Data.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// fixtures 构造可见性矩阵的标准夹具(§13)。
|
||||||
|
type fixtures struct {
|
||||||
|
admin *http.Client
|
||||||
|
anon *http.Client
|
||||||
|
pubID int64 // 公开笔记(引用 imgPublic)
|
||||||
|
privID int64 // 私有笔记(引用 imgPrivate)
|
||||||
|
trashID int64 // 回收站笔记(原公开,引用 imgTrash)
|
||||||
|
pubSlug string
|
||||||
|
privSlug string
|
||||||
|
trashSlug string
|
||||||
|
imgPub int64
|
||||||
|
imgPriv int64
|
||||||
|
imgTrash int64
|
||||||
|
imgOrphan int64
|
||||||
|
}
|
||||||
|
|
||||||
|
var png1x1 = []byte{
|
||||||
|
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D,
|
||||||
|
0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
||||||
|
0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE, 0x00, 0x00, 0x00,
|
||||||
|
0x0C, 0x49, 0x44, 0x41, 0x54, 0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00,
|
||||||
|
0x00, 0x03, 0x01, 0x01, 0x00, 0x18, 0xDD, 0x8D, 0xB0, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
|
||||||
|
}
|
||||||
|
|
||||||
|
// uniquePNG 追加 IEND 之后的差异化尾部,绕开 sha256 去重(设计 §14:
|
||||||
|
// 去重会合并同字节图片;夹具需要四张不同图)。
|
||||||
|
func uniquePNG(tag byte) []byte {
|
||||||
|
b := make([]byte, len(png1x1), len(png1x1)+8)
|
||||||
|
copy(b, png1x1)
|
||||||
|
return append(b, 0, 0, 0, 0, 't', 'a', 'g', tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *testEnv) fixtures() *fixtures {
|
||||||
|
e.t.Helper()
|
||||||
|
f := &fixtures{admin: e.loginAdmin(), anon: e.client()}
|
||||||
|
f.imgPub = e.uploadPNG(f.admin, uniquePNG('a'))
|
||||||
|
f.imgPriv = e.uploadPNG(f.admin, uniquePNG('b'))
|
||||||
|
f.imgTrash = e.uploadPNG(f.admin, uniquePNG('c'))
|
||||||
|
f.imgOrphan = e.uploadPNG(f.admin, uniquePNG('d'))
|
||||||
|
|
||||||
|
pub := e.createNote(f.admin, "pub-note", "公开笔记Alpha", "公开内容 +")", "public", []string{"公开"})
|
||||||
|
priv := e.createNote(f.admin, "priv-note", "私有笔记Beta", "私有内容 +")", "private", []string{"秘密"})
|
||||||
|
trash := e.createNote(f.admin, "trash-note", "回收站笔记Gamma", "回收站内容 +")", "public", []string{"公开"})
|
||||||
|
|
||||||
|
f.pubID = int64(pub["id"].(float64))
|
||||||
|
f.privID = int64(priv["id"].(float64))
|
||||||
|
f.trashID = int64(trash["id"].(float64))
|
||||||
|
f.pubSlug = pub["slug"].(string)
|
||||||
|
f.privSlug = priv["slug"].(string)
|
||||||
|
f.trashSlug = trash["slug"].(string)
|
||||||
|
|
||||||
|
// 软删除 trash-note
|
||||||
|
resp, body := e.do(f.admin, http.MethodDelete, fmt.Sprintf("/api/admin/notes/%d", f.trashID), nil, e.adminHeaders())
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
e.t.Fatalf("软删除失败: %d %s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestVisibilityMatrix 核心不变量(§13):主体 × 笔记状态 × 出口 的表驱动矩阵。
|
||||||
|
// 判定规则:匿名不可见 ⇒ 404 或输出中不含。
|
||||||
|
func TestVisibilityMatrix(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
f := e.fixtures()
|
||||||
|
|
||||||
|
type outlet struct {
|
||||||
|
name string
|
||||||
|
probe func(c *http.Client, subject string) (status int, body string)
|
||||||
|
}
|
||||||
|
|
||||||
|
contains := func(body, sub string) bool { return strings.Contains(body, sub) }
|
||||||
|
|
||||||
|
outlets := []outlet{
|
||||||
|
{
|
||||||
|
name: "列表/api/notes",
|
||||||
|
probe: func(c *http.Client, _ string) (int, string) {
|
||||||
|
resp, b := e.get(c, "/api/notes?page_size=100")
|
||||||
|
return resp.StatusCode, string(b)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "详情/api/notes/{slug}",
|
||||||
|
probe: func(c *http.Client, slug string) (int, string) {
|
||||||
|
resp, b := e.get(c, "/api/notes/"+slug)
|
||||||
|
return resp.StatusCode, string(b)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "标签/api/tags",
|
||||||
|
probe: func(c *http.Client, _ string) (int, string) {
|
||||||
|
resp, b := e.get(c, "/api/tags")
|
||||||
|
return resp.StatusCode, string(b)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "RSS/feed.xml",
|
||||||
|
probe: func(c *http.Client, _ string) (int, string) {
|
||||||
|
resp, b := e.get(c, "/feed.xml")
|
||||||
|
return resp.StatusCode, string(b)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sitemap.xml",
|
||||||
|
probe: func(c *http.Client, _ string) (int, string) {
|
||||||
|
resp, b := e.get(c, "/sitemap.xml")
|
||||||
|
return resp.StatusCode, string(b)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "HTML meta 注入",
|
||||||
|
probe: func(c *http.Client, slug string) (int, string) {
|
||||||
|
resp, b := e.get(c, "/notes/"+slug)
|
||||||
|
return resp.StatusCode, string(b)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// (出口, 状态特征, 匿名期望, 管理员期望)
|
||||||
|
type expect struct {
|
||||||
|
anonVisible bool
|
||||||
|
adminVisible bool
|
||||||
|
}
|
||||||
|
notes := []struct {
|
||||||
|
slug string
|
||||||
|
title string
|
||||||
|
tag string
|
||||||
|
visible expect
|
||||||
|
}{
|
||||||
|
{f.pubSlug, "公开笔记Alpha", "公开", expect{true, true}},
|
||||||
|
{f.privSlug, "私有笔记Beta", "秘密", expect{false, false}}, // 管理员也只在详情/admin 出口可见
|
||||||
|
{f.trashSlug, "回收站笔记Gamma", "公开", expect{false, false}},
|
||||||
|
{"no-such-note", "不存在笔记", "无", expect{false, false}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, n := range notes {
|
||||||
|
for _, o := range outlets {
|
||||||
|
t.Run(o.name+"/"+n.slug, func(t *testing.T) {
|
||||||
|
statusAnon, bodyAnon := o.probe(f.anon, n.slug)
|
||||||
|
statusAdmin, bodyAdmin := o.probe(f.admin, n.slug)
|
||||||
|
|
||||||
|
if statusAnon >= 500 || statusAdmin >= 500 {
|
||||||
|
t.Fatalf("5xx: anon=%d admin=%d", statusAnon, statusAdmin)
|
||||||
|
}
|
||||||
|
// 详情出口:非公开匿名必须 404;HTML meta 出口走 SPA fallback(200),
|
||||||
|
// 按 §13 用「输出中不含」判定(下方 meta 内容断言)
|
||||||
|
isDetail := strings.Contains(o.name, "详情")
|
||||||
|
isMeta := strings.Contains(o.name, "meta")
|
||||||
|
// 匿名判定
|
||||||
|
if isDetail {
|
||||||
|
if n.visible.anonVisible {
|
||||||
|
if statusAnon != http.StatusOK {
|
||||||
|
t.Errorf("匿名应可见但状态 %d", statusAnon)
|
||||||
|
}
|
||||||
|
} else if statusAnon != http.StatusNotFound {
|
||||||
|
t.Errorf("匿名不可见应为 404,实际 %d(body: %.100s)", statusAnon, bodyAnon)
|
||||||
|
}
|
||||||
|
} else if !isMeta {
|
||||||
|
if !n.visible.anonVisible && (contains(bodyAnon, n.title) || contains(bodyAnon, n.slug)) {
|
||||||
|
t.Errorf("匿名输出中不应出现 %q/%q", n.title, n.slug)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// meta 注入内容判定:公开 → 注入笔记标题;非公开匿名 → 站点默认标题
|
||||||
|
if strings.Contains(o.name, "meta") {
|
||||||
|
if n.visible.anonVisible {
|
||||||
|
if !contains(bodyAnon, "<title>"+n.title) {
|
||||||
|
t.Errorf("公开笔记 meta 应注入笔记标题,body: %s", bodyAnon)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if contains(bodyAnon, n.title) {
|
||||||
|
t.Errorf("非公开笔记 meta 不应注入笔记标题")
|
||||||
|
}
|
||||||
|
if !contains(bodyAnon, "<title>测试站</title>") {
|
||||||
|
t.Errorf("非公开笔记 meta 应回退站点默认标题,body: %s", bodyAnon)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 管理员判定:列表/标签/RSS/sitemap 仍只含公开内容
|
||||||
|
if strings.Contains(o.name, "列表") || strings.Contains(o.name, "标签") ||
|
||||||
|
strings.Contains(o.name, "RSS") || strings.Contains(o.name, "sitemap") {
|
||||||
|
if contains(bodyAdmin, n.title) && !n.visible.anonVisible {
|
||||||
|
t.Errorf("管理员的公开聚合出口(%s)也不应包含非公开内容 %q", o.name, n.title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 详情:管理员可读公开+私有,不可读回收站/不存在
|
||||||
|
if isDetail {
|
||||||
|
switch {
|
||||||
|
case n.slug == f.pubSlug:
|
||||||
|
if statusAdmin != http.StatusOK || !contains(bodyAdmin, `"status":"public"`) {
|
||||||
|
t.Errorf("管理员读公开笔记失败: %d", statusAdmin)
|
||||||
|
}
|
||||||
|
case n.slug == f.privSlug:
|
||||||
|
if statusAdmin != http.StatusOK || !contains(bodyAdmin, `"status":"private"`) {
|
||||||
|
t.Errorf("管理员应可私有预览: %d body: %.200s", statusAdmin, bodyAdmin)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if statusAdmin != http.StatusNotFound {
|
||||||
|
t.Errorf("管理员读 %s 应 404(回收站仅经 /api/admin/trash),实际 %d", n.slug, statusAdmin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 图片出口(并集语义 + 缓存头分流,§6.2/§7.4)----
|
||||||
|
imageCases := []struct {
|
||||||
|
name string
|
||||||
|
id int64
|
||||||
|
anonCode int
|
||||||
|
anonCache string
|
||||||
|
adminCode int
|
||||||
|
adminCache string
|
||||||
|
}{
|
||||||
|
{"公开图", f.imgPub, 200, "public, max-age=31536000, immutable", 200, "public, max-age=31536000, immutable"},
|
||||||
|
{"私有图", f.imgPriv, 404, "", 200, "private, no-store"},
|
||||||
|
{"回收站图", f.imgTrash, 404, "", 200, "private, no-store"},
|
||||||
|
{"孤儿图", f.imgOrphan, 404, "", 200, "private, no-store"},
|
||||||
|
{"不存在图", 99999, 404, "", 404, ""},
|
||||||
|
}
|
||||||
|
for _, ic := range imageCases {
|
||||||
|
t.Run("图片/"+ic.name, func(t *testing.T) {
|
||||||
|
path := "/api/images/" + fmt.Sprint(ic.id)
|
||||||
|
respA, _ := e.get(f.anon, path)
|
||||||
|
if respA.StatusCode != ic.anonCode {
|
||||||
|
t.Errorf("匿名期望 %d 实际 %d", ic.anonCode, respA.StatusCode)
|
||||||
|
}
|
||||||
|
if ic.anonCache != "" && respA.Header.Get("Cache-Control") != ic.anonCache {
|
||||||
|
t.Errorf("匿名缓存头期望 %q 实际 %q", ic.anonCache, respA.Header.Get("Cache-Control"))
|
||||||
|
}
|
||||||
|
respM, _ := e.get(f.admin, path)
|
||||||
|
if respM.StatusCode != ic.adminCode {
|
||||||
|
t.Errorf("管理员期望 %d 实际 %d", ic.adminCode, respM.StatusCode)
|
||||||
|
}
|
||||||
|
if ic.adminCache != "" && respM.Header.Get("Cache-Control") != ic.adminCache {
|
||||||
|
t.Errorf("管理员缓存头期望 %q 实际 %q", ic.adminCache, respM.Header.Get("Cache-Control"))
|
||||||
|
}
|
||||||
|
// 统一 404 不泄露存在性:私有与不存在响应体一致
|
||||||
|
if ic.anonCode == 404 {
|
||||||
|
_, b1 := e.get(f.anon, path)
|
||||||
|
_, b2 := e.get(f.anon, "/api/images/99998")
|
||||||
|
if string(b1) != string(b2) {
|
||||||
|
t.Errorf("404 响应体应统一(防枚举): %q vs %q", b1, b2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 管理接口权限 ----
|
||||||
|
t.Run("管理接口权限", func(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
method, path string
|
||||||
|
}{
|
||||||
|
{http.MethodGet, "/api/admin/notes"},
|
||||||
|
{http.MethodGet, "/api/admin/trash"},
|
||||||
|
{http.MethodGet, "/api/admin/settings"},
|
||||||
|
} {
|
||||||
|
resp, _ := e.get(f.anon, tc.path)
|
||||||
|
if resp.StatusCode != http.StatusUnauthorized {
|
||||||
|
t.Errorf("匿名 %s %s 应 401,实际 %d", tc.method, tc.path, resp.StatusCode)
|
||||||
|
}
|
||||||
|
respM, _ := e.get(f.admin, tc.path)
|
||||||
|
if respM.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("管理员 %s %s 应 200,实际 %d", tc.method, tc.path, respM.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 回收站内容仅经 /api/admin/trash 可见
|
||||||
|
resp, body := e.get(f.admin, "/api/admin/trash")
|
||||||
|
if resp.StatusCode != 200 || !contains(string(body), "回收站笔记Gamma") {
|
||||||
|
t.Errorf("回收站列表应包含已删笔记: %d %s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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