Files
pure-note/internal/httpapi/admin.go
T
wangairnan 1272d680a8 fix(httpapi): no-store 接线、图片缓存头时序、解压炸弹上限与 RSS 空日期
/api/admin/* 挂 NoStore、/api/me 内联 no-store(P1-3); 图片缓存头移至数据读取成功后,404 不携带 public immutable(P2-9); 上传先 DecodeConfig 限制像素 ≤2^25 再解码(P2-12); 无公开笔记时省略 lastBuildDate(P2-10)。
2026-09-08 17:32:52 +08:00

561 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(&notes[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(&notes[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)----
// maxImagePixels 解码像素总数上限(1<<25 ≈ 8K 分辨率 7680×4320 ≈ 3.3×10⁷),
// 防高压缩比小体积图片解码后内存放大(解压炸弹)。
const maxImagePixels = 1 << 25
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 由魔数保证)——拦截截断/伪造的图片流。
// 先 DecodeConfig 限制像素总数:防 ≤5MB 高压缩比图片解码后撑爆内存
// (解压炸弹 OOM,评审 round2 P2-12)。
if magicMime != "image/webp" {
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil {
writeError(w, http.StatusUnsupportedMediaType, "unsupported_media", "图片解码失败")
return
}
if cfg.Width <= 0 || cfg.Height <= 0 || int64(cfg.Width)*int64(cfg.Height) > maxImagePixels {
writeError(w, http.StatusRequestEntityTooLarge, "too_large", "图片像素总数超过上限")
return
}
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)
}
}