diff --git a/cmd/pure-note/main.go b/cmd/pure-note/main.go new file mode 100644 index 0000000..5e6bbeb --- /dev/null +++ b/cmd/pure-note/main.go @@ -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 +} diff --git a/internal/httpapi/admin.go b/internal/httpapi/admin.go new file mode 100644 index 0000000..15b2289 --- /dev/null +++ b/internal/httpapi/admin.go @@ -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) + } +} diff --git a/internal/httpapi/api_test.go b/internal/httpapi/api_test.go new file mode 100644 index 0000000..2e14d1e --- /dev/null +++ b/internal/httpapi/api_test.go @@ -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, "", "生命周期", "引用 ![x](/api/images/"+fmt.Sprint(imgID)+")", "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(``) + 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) +} diff --git a/internal/httpapi/auth.go b/internal/httpapi/auth.go new file mode 100644 index 0000000..71248a0 --- /dev/null +++ b/internal/httpapi/auth.go @@ -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}) +} diff --git a/internal/httpapi/feed.go b/internal/httpapi/feed.go new file mode 100644 index 0000000..ab28400 --- /dev/null +++ b/internal/httpapi/feed.go @@ -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")) +} diff --git a/internal/httpapi/helpers.go b/internal/httpapi/helpers.go new file mode 100644 index 0000000..d8f5ca3 --- /dev/null +++ b/internal/httpapi/helpers.go @@ -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) } diff --git a/internal/httpapi/public.go b/internal/httpapi/public.go new file mode 100644 index 0000000..0c4d9c2 --- /dev/null +++ b/internal/httpapi/public.go @@ -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) +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go new file mode 100644 index 0000000..5220a99 --- /dev/null +++ b/internal/httpapi/server.go @@ -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 +} diff --git a/internal/httpapi/testenv_test.go b/internal/httpapi/testenv_test.go new file mode 100644 index 0000000..bd5af8a --- /dev/null +++ b/internal/httpapi/testenv_test.go @@ -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( + `{{.Title}}SPA`)) + 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", "公开内容 ![x](/api/images/"+fmt.Sprint(f.imgPub)+")", "public", []string{"公开"}) + priv := e.createNote(f.admin, "priv-note", "私有笔记Beta", "私有内容 ![y](/api/images/"+fmt.Sprint(f.imgPriv)+")", "private", []string{"秘密"}) + trash := e.createNote(f.admin, "trash-note", "回收站笔记Gamma", "回收站内容 ![z](/api/images/"+fmt.Sprint(f.imgTrash)+")", "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 +} diff --git a/internal/httpapi/visibility_test.go b/internal/httpapi/visibility_test.go new file mode 100644 index 0000000..ea3c5ae --- /dev/null +++ b/internal/httpapi/visibility_test.go @@ -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, ""+n.title) { + t.Errorf("公开笔记 meta 应注入笔记标题,body: %s", bodyAnon) + } + } else { + if contains(bodyAnon, n.title) { + t.Errorf("非公开笔记 meta 不应注入笔记标题") + } + if !contains(bodyAnon, "<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) + } + }) +} diff --git a/internal/webui/webui.go b/internal/webui/webui.go new file mode 100644 index 0000000..ff01c3a --- /dev/null +++ b/internal/webui/webui.go @@ -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( + `Pure Note +

Pure Note

前端静态资源尚未构建:请在仓库根目录执行 make build(会先构建 web/dist 并拷贝到 internal/webui/dist)。

`)) + +// 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()) +} diff --git a/internal/webui/webui_test.go b/internal/webui/webui_test.go new file mode 100644 index 0000000..89418ea --- /dev/null +++ b/internal/webui/webui_test.go @@ -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(`{{.Title}}`)}, + "assets/app-abc123.js": &fstest.MapFile{Data: []byte("console.log(1)")}, + "favicon.svg": &fstest.MapFile{Data: []byte("")}, + } +} + +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(), "站点") { + t.Errorf("根路径应渲染默认 meta: %d %s", rec.Code, rec.Body.String()) + } + // SPA 深链:注入笔记 meta + rec = get(t, h, "/notes/hello") + if !strings.Contains(rec.Body.String(), "笔记标题 - 站点") { + t.Errorf("深链应注入笔记 meta: %s", rec.Body.String()) + } + // html/template 自动转义:标题含恶意内容不破坏标签结构 + h2 := u.Handler(func(r *http.Request) Meta { + return Meta{Title: ``, OGTitle: `" onclick="x`} + }) + rec = get(t, h2, "/notes/evil") + body := rec.Body.String() + if strings.Contains(body, "