HTTP 层与单二进制入口:路由 handler、会话/CSRF/限流防线、SPA 嵌入与 meta 注入
- internal/httpapi:§7.1 全部路由(公开浏览 / 管理端 / 认证 / feed), 服务端统一可见性过滤(含回收站仅 admin 出口)、图片魔数校验与 immutable/no-store 缓存头分流、统一 404 防枚举、slug 自解冲突与 409 字段级错误、fail-only 登录限流(429 + Retry-After)、 设置白名单(永不序列化口令哈希) - internal/webui:go:embed dist + SPA fallback(资产指纹长缓存、 深链回退 index.html)+ html/template 元信息注入(仅可见笔记) - cmd/pure-note:serve/init/backup/gc/version 子命令,优雅停机与 每小时会话清理 - 含全部 §13 测试组:表驱动可见性矩阵、迁移守卫、认证会话、CSRF、 上传、回收站/gc、slug 策略、设置白名单、webui MapFS 单测
This commit is contained in:
@@ -0,0 +1,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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user