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"` PublishedAt int64 `json:"published_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, PublishedAt: n.PublishedAt, } } // 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 笔记写请求体。PublishedAt 为发布日期(Unix 秒);nil = 创建取当前时刻 / 更新保持不变。 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"` PublishedAt *int64 `json:"published_at"` } 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) } publishedAt := now if p.PublishedAt != nil && *p.PublishedAt > 0 { publishedAt = *p.PublishedAt } 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, PublishedAt: publishedAt, 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 p.PublishedAt != nil && *p.PublishedAt > 0 { n.PublishedAt = *p.PublishedAt } 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)}) } // handleAdminTrashEmpty DELETE /api/admin/trash:清空回收站(物理删除全部,§6.2)。 func (s *Server) handleAdminTrashEmpty(w http.ResponseWriter, r *http.Request) { n, err := s.st.EmptyTrash() if err != nil { writeError(w, http.StatusInternalServerError, "internal", "内部错误") return } s.log.Info("admin_action", "op", "trash.empty", "count", n) writeJSON(w, http.StatusOK, map[string]any{"ok": true, "deleted": n}) } // 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"` BeianNo *string `json:"beian_no"` SiteLogo *string `json:"site_logo"` } // 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/beian_no/site_logo)") 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 !store.ValidPageSize(*dto.PageSize) { writeError(w, http.StatusBadRequest, "bad_request", "page_size 仅支持 10/20/30/50") return } if err := s.st.SetSetting(store.KeyPageSize, strconv.Itoa(*dto.PageSize)); err != nil { writeError(w, http.StatusInternalServerError, "internal", "内部错误") return } } if dto.BeianNo != nil { v := strings.TrimSpace(*dto.BeianNo) if len([]rune(v)) > 200 { writeError(w, http.StatusBadRequest, "bad_request", "备案号 ≤200 字符") return } if err := s.st.SetSetting(store.KeyBeianNo, v); err != nil { writeError(w, http.StatusInternalServerError, "internal", "内部错误") return } } if dto.SiteLogo != nil { // 仅收站内绝对路径(如 /api/images/1):CSP img-src 'self',外链图片本来就无法展示 v := strings.TrimSpace(*dto.SiteLogo) if v != "" && (len(v) > 500 || strings.ContainsAny(v, " \t\r\n\"'\\") || !strings.HasPrefix(v, "/")) { writeError(w, http.StatusBadRequest, "bad_request", "站点 Logo 须为站内绝对路径(如 /api/images/1)或留空") return } if err := s.st.SetSetting(store.KeySiteLogo, v); 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) } }