- 编辑页标题改 filled 变体;标签改 tags 选择器(可勾选既有/输入新建) - 笔记发布日期可自选(schema v2 新增 published_at 并回填),前台展示发布日期 - 登录页移除「请输入管理密码以继续」「连续失败将被暂时锁定」文案 - 站点设置每页条数收敛为 10/20/30/50 选择器,后端白名单同源校验 - 站点设置两卡片宽屏左右/窄屏上下;修改密码按钮改常规大小 - 后台各页顶栏固定高度,切换页面不再抖动 - 站点 Logo:settings 新增 site_logo(站内路径),gc/孤儿清单豁免 Logo 图片, 博客 header 站点名前展示,设置页支持上传/更换/清除 - 回收站右上角一键清空(DELETE /api/admin/trash,含确认弹窗) - pn init 写入 Markdown 语法示例文档(slug welcome,公开)与程序生成的示例图片
607 lines
20 KiB
Go
607 lines
20 KiB
Go
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)
|
||
}
|
||
}
|