From efc30156b9c6ddd74ea7632dc8200920d5879fdf Mon Sep 17 00:00:00 2001 From: kevin Date: Thu, 27 Aug 2026 19:52:07 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=AB=99=E7=82=B9=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=20JSON=20=E5=8C=96=E2=80=94=E2=80=94/api/adm?= =?UTF-8?q?in/settings/*?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - settings.go:5 组 save handler 改 JSON 绑定(siteSettingsRequest/ uploadSettingsRequest/downloadSettingsRequest/commentSettingsRequest/ navLinkSettingsRequest);request struct 变更子函数签名(不再触碰 c.PostForm),upload/navlink/download 的 action 分发保留,未知 action 返回 400;enabled 用 *bool(nil 沿用旧默认启用语义) - favicon/logo 上传拆出:POST /api/admin/settings/site/favicon|logo (multipart,图片类别校验 + 旧本地文件替换),SiteSettingsSave 只 处理文本/URL/clear(存储路径校验保留 illegal_dir 400) - main.go:设置旧 POST 路由移除,新 /api/admin/settings 分组注册 - 模板:base.html 新增 blogSettingsForm 委托(data-api-url/data-action/ data-confirm → POST + redirect/alert);settings_site 主表单 JSON + logo/favicon 选择即上传;navlinks/upload/download/comments 页全部 小表单改委托(约 14 个) - blogForm:剔除 file 字段(文件走 multipart) - 测试:TestStorageDirTraversalRejected、TestAddUploadFileTypeRejectsDangerousExtensions 更新 JSON 断言;env 路由补 /api/admin/settings - main_test 冒烟补设置 API 断言;go build/vet/test 全绿 --- handlers/p2_validation_test.go | 24 +- handlers/security_test.go | 6 +- handlers/session_upload_security_test.go | 29 +- handlers/settings.go | 507 ++++++++++++++--------- main.go | 17 +- main_test.go | 8 + templates/admin/settings_comment.html | 4 +- templates/admin/settings_download.html | 21 +- templates/admin/settings_navlinks.html | 18 +- templates/admin/settings_site.html | 52 ++- templates/admin/settings_upload.html | 26 +- templates/layouts/base.html | 21 + 12 files changed, 450 insertions(+), 283 deletions(-) diff --git a/handlers/p2_validation_test.go b/handlers/p2_validation_test.go index 81471eb..d1c9b81 100644 --- a/handlers/p2_validation_test.go +++ b/handlers/p2_validation_test.go @@ -40,15 +40,11 @@ func TestStorageDirTraversalRejected(t *testing.T) { ".", } for _, dir := range cases { - fields := url.Values{} - fields.Set("action", "save_config") - fields.Set("storage_dir", dir) - w := postForm(e, http.MethodPost, "/admin/settings/upload", admin, token, fields) - if w.Code != http.StatusFound { - t.Fatalf("storage_dir %q: status = %d, want 302", dir, w.Code) - } - if loc := w.Header().Get("Location"); !strings.Contains(loc, "illegal_dir") { - t.Fatalf("storage_dir %q: location = %q, want illegal_dir error", dir, loc) + w := postJSON(e, http.MethodPost, "/api/admin/settings/upload", admin, token, + gin.H{"action": "save_config", "storage_dir": dir}) + if w.Code != http.StatusBadRequest || respCode(w) != "settings_upload_illegal_dir" { + t.Fatalf("storage_dir %q: status = %d, code = %q, want 400/settings_upload_illegal_dir", + dir, w.Code, respCode(w)) } // 存储的值必须保持不变。 var u models.UploadConfig @@ -61,12 +57,10 @@ func TestStorageDirTraversalRejected(t *testing.T) { } // 安全的单段值可被接受。 - fields := url.Values{} - fields.Set("action", "save_config") - fields.Set("storage_dir", "my_attach-2") - w := postForm(e, http.MethodPost, "/admin/settings/upload", admin, token, fields) - if w.Code != http.StatusFound || strings.Contains(w.Header().Get("Location"), "illegal_dir") { - t.Fatalf("safe storage_dir: status = %d, location = %q", w.Code, w.Header().Get("Location")) + w := postJSON(e, http.MethodPost, "/api/admin/settings/upload", admin, token, + gin.H{"action": "save_config", "storage_dir": "my_attach-2"}) + if w.Code != http.StatusOK || !respOK(w) { + t.Fatalf("safe storage_dir: status = %d, body %s", w.Code, w.Body.String()) } var u models.UploadConfig if err := e.db.First(&u, 1).Error; err != nil { diff --git a/handlers/security_test.go b/handlers/security_test.go index c6f9df7..9964d31 100644 --- a/handlers/security_test.go +++ b/handlers/security_test.go @@ -111,10 +111,10 @@ func newSecurityTestEnv(t *testing.T) *securityTestEnv { profile.POST("/avatar", UploadAvatar(db, storageDir)) } - // 上传设置路由(危险扩展名黑名单覆盖,#21)。 - adminSettings := r.Group("/admin/settings", middleware.AuthRequired(db), middleware.AdminRequired(db)) + // 上传设置 API(危险扩展名黑名单覆盖,#21)。 + adminSettingsAPI := r.Group("/api/admin/settings", middleware.AuthRequired(db), middleware.AdminRequired(db)) { - adminSettings.POST("/upload", UploadSettingsSave(db)) + adminSettingsAPI.POST("/upload", UploadSettingsSave(db)) } // 后台用户管理路由(SQL 注入回归覆盖,#19)。 diff --git a/handlers/session_upload_security_test.go b/handlers/session_upload_security_test.go index b96bac0..0ae4df4 100644 --- a/handlers/session_upload_security_test.go +++ b/handlers/session_upload_security_test.go @@ -9,7 +9,6 @@ import ( "mime/multipart" "net/http" "net/http/httptest" - "net/url" "os" "path/filepath" "strings" @@ -161,16 +160,11 @@ func TestAddUploadFileTypeRejectsDangerousExtensions(t *testing.T) { token := e.csrfTokenFor(t, admin) for _, ext := range []string{"html", ".htm", "SVG", "xhtml", ".xml", "js"} { - form := url.Values{} - form.Set("_csrf", token) - form.Set("action", "add_type") - form.Set("extension", ext) - form.Set("category", models.CategoryImage) - w := e.do(http.MethodPost, "/admin/settings/upload", admin, - strings.NewReader(form.Encode()), "application/x-www-form-urlencoded") - if w.Code != http.StatusFound || !strings.Contains(w.Header().Get("Location"), "error=dangerous_ext") { - t.Fatalf("add type %q: status=%d location=%q, want 302 with error=dangerous_ext", - ext, w.Code, w.Header().Get("Location")) + w := postJSON(e, http.MethodPost, "/api/admin/settings/upload", admin, token, + gin.H{"action": "add_type", "extension": ext, "category": models.CategoryImage}) + if w.Code != http.StatusBadRequest || respCode(w) != "settings_upload_dangerous_ext" { + t.Fatalf("add type %q: status=%d code=%q, want 400/settings_upload_dangerous_ext", + ext, w.Code, respCode(w)) } var count int64 normalized := strings.ToLower(ext) @@ -184,15 +178,10 @@ func TestAddUploadFileTypeRejectsDangerousExtensions(t *testing.T) { } // 对照组:良性的扩展名仍然被接受。 - form := url.Values{} - form.Set("_csrf", token) - form.Set("action", "add_type") - form.Set("extension", "md") - form.Set("category", models.CategoryDocument) - w := e.do(http.MethodPost, "/admin/settings/upload", admin, - strings.NewReader(form.Encode()), "application/x-www-form-urlencoded") - if w.Code != http.StatusFound || !strings.Contains(w.Header().Get("Location"), "saved=1") { - t.Fatalf("add benign type: status=%d location=%q", w.Code, w.Header().Get("Location")) + w := postJSON(e, http.MethodPost, "/api/admin/settings/upload", admin, token, + gin.H{"action": "add_type", "extension": "md", "category": models.CategoryDocument}) + if w.Code != http.StatusOK || !respOK(w) { + t.Fatalf("add benign type: status=%d body=%s", w.Code, w.Body.String()) } var count int64 e.db.Model(&models.UploadFileType{}).Where("extension = ?", ".md").Count(&count) diff --git a/handlers/settings.go b/handlers/settings.go index 7c188e9..0b38c88 100644 --- a/handlers/settings.go +++ b/handlers/settings.go @@ -56,6 +56,35 @@ func userIDFromSession(c *gin.Context) uint { return 0 } +// mbFromFloat 将兆字节数(浮点,来自 JSON)转换为字节。 +func mbFromFloat(v float64) int64 { + if v <= 0 { + return 0 + } + return int64(v * 1024 * 1024) +} + +// siteSettingsRequest 是 POST /api/admin/settings/site 的 JSON 请求体。 +// 文本字段为空即存储为空;favicon/logo 的清除与 URL 设置通过专用字段。 +type siteSettingsRequest struct { + LogoTextZh string `json:"logo_text_zh"` + LogoTextEn string `json:"logo_text_en"` + HeaderTextZh string `json:"header_text_zh"` + HeaderTextEn string `json:"header_text_en"` + HomeWelcomeZh string `json:"home_welcome_zh"` + HomeWelcomeEn string `json:"home_welcome_en"` + HomeSubtitleZh string `json:"home_subtitle_zh"` + HomeSubtitleEn string `json:"home_subtitle_en"` + FooterTextZh string `json:"footer_text_zh"` + FooterTextEn string `json:"footer_text_en"` + SiteURL string `json:"site_url"` + AllowRegistration bool `json:"allow_registration"` + FaviconURL string `json:"favicon_url"` + FaviconClear bool `json:"favicon_clear"` + LogoURL string `json:"logo_url"` + LogoClear bool `json:"logo_clear"` +} + // ---------------- 站点设置 ---------------- // SiteSettingsPage 渲染站点显示设置表单。 @@ -81,123 +110,142 @@ func SiteSettingsPage(db *gorm.DB) gin.HandlerFunc { } } -// SiteSettingsSave 处理站点设置的徽标上传与文本字段。 +// SiteSettingsSave 处理站点设置的文本字段。 +// favicon/logo 文件上传走 SiteFaviconUpload / SiteLogoUpload(multipart)。 func SiteSettingsSave(db *gorm.DB, storagePath string) gin.HandlerFunc { + return func(c *gin.Context) { + var req siteSettingsRequest + if !bindJSON(c, &req) { + return + } + + var s models.SiteSetting + if err := db.First(&s, 1).Error; err != nil { + s = models.SiteSetting{ID: 1} + } + + s.LogoTextZh = strings.TrimSpace(req.LogoTextZh) + s.LogoTextEn = strings.TrimSpace(req.LogoTextEn) + s.HeaderTextZh = strings.TrimSpace(req.HeaderTextZh) + s.HeaderTextEn = strings.TrimSpace(req.HeaderTextEn) + s.HomeWelcomeZh = strings.TrimSpace(req.HomeWelcomeZh) + s.HomeWelcomeEn = strings.TrimSpace(req.HomeWelcomeEn) + s.HomeSubtitleZh = strings.TrimSpace(req.HomeSubtitleZh) + s.HomeSubtitleEn = strings.TrimSpace(req.HomeSubtitleEn) + s.FooterTextZh = strings.TrimSpace(req.FooterTextZh) + s.FooterTextEn = strings.TrimSpace(req.FooterTextEn) + // SECURITY_TODO #16:规范化的 feed/站点 URL;RSS 使用它而非请求的 + // Host,以避免 Host 头污染。 + s.SiteURL = strings.TrimSpace(req.SiteURL) + s.AllowRegistration = req.AllowRegistration + s.UpdatedBy = userIDFromSession(c) + + // favicon:URL 设置优先;clear 标志移除本地文件。 + if req.FaviconClear { + if s.Favicon != "" && !s.FaviconIsURL() { + os.Remove(filepath.Join(storagePath, "logos", s.Favicon)) + } + s.Favicon = "" + } else if faviconURL := strings.TrimSpace(req.FaviconURL); faviconURL != "" { + s.Favicon = faviconURL + } + + // logo:URL 设置优先;clear 标志移除本地文件。 + if req.LogoClear { + if s.Logo != "" && !s.LogoIsURL() { + os.Remove(filepath.Join(storagePath, "logos", s.Logo)) + } + s.Logo = "" + } else if logoURL := strings.TrimSpace(req.LogoURL); logoURL != "" { + s.Logo = logoURL + } + + if err := db.Save(&s).Error; err != nil { + APIError(c, http.StatusInternalServerError, "api_error") + return + } + models.RefreshConfigCache(db) + APIOK(c, "/admin/settings/site?saved=1", nil) + } +} + +// SiteFaviconUpload 上传 favicon 图片(multipart,字段名 favicon)。 +// 校验类别为图片后存储到 logos/,并替换旧本地文件。 +func SiteFaviconUpload(db *gorm.DB, storagePath string) gin.HandlerFunc { + return saveSiteImage(db, storagePath, "favicon", "favicon") +} + +// SiteLogoUpload 上传站点 logo 图片(multipart,字段名 logo)。 +func SiteLogoUpload(db *gorm.DB, storagePath string) gin.HandlerFunc { + return saveSiteImage(db, storagePath, "logo", "logo") +} + +// saveSiteImage 保存站点 favicon/logo 的公共实现。 +// fieldName 是 multipart 字段名;prefix 是存储文件名前缀。 +func saveSiteImage(db *gorm.DB, storagePath, fieldName, prefix string) gin.HandlerFunc { return func(c *gin.Context) { var s models.SiteSetting if err := db.First(&s, 1).Error; err != nil { s = models.SiteSetting{ID: 1} } - s.LogoTextZh = strings.TrimSpace(c.PostForm("logo_text_zh")) - s.LogoTextEn = strings.TrimSpace(c.PostForm("logo_text_en")) - s.HeaderTextZh = strings.TrimSpace(c.PostForm("header_text_zh")) - s.HeaderTextEn = strings.TrimSpace(c.PostForm("header_text_en")) - s.HomeWelcomeZh = strings.TrimSpace(c.PostForm("home_welcome_zh")) - s.HomeWelcomeEn = strings.TrimSpace(c.PostForm("home_welcome_en")) - s.HomeSubtitleZh = strings.TrimSpace(c.PostForm("home_subtitle_zh")) - s.HomeSubtitleEn = strings.TrimSpace(c.PostForm("home_subtitle_en")) - s.FooterTextZh = strings.TrimSpace(c.PostForm("footer_text_zh")) - s.FooterTextEn = strings.TrimSpace(c.PostForm("footer_text_en")) - // SECURITY_TODO #16:规范化的 feed/站点 URL;RSS 使用它而非请求的 - // Host,以避免 Host 头污染。 - s.SiteURL = strings.TrimSpace(c.PostForm("site_url")) - s.AllowRegistration = c.PostForm("allow_registration") == "1" - s.UpdatedBy = userIDFromSession(c) + file, header, err := c.Request.FormFile(fieldName) + if err != nil { + APIError(c, http.StatusBadRequest, "api_invalid_request") + return + } + defer file.Close() + check := ValidateUpload(header) + if !check.OK || check.Type.Category != models.CategoryImage { + APIError(c, http.StatusBadRequest, "settings_upload_dangerous_ext") + return + } - // Favicon 上传(可选)。favicon_url 表单字段优先于上传文件, - // 因此管理员可以设置本地文件或外链。 - if faviconURL := strings.TrimSpace(c.PostForm("favicon_url")); faviconURL != "" { - s.Favicon = faviconURL - } else if file, header, err := c.Request.FormFile("favicon"); err == nil { - defer file.Close() - check := ValidateUpload(header) - if !check.OK || check.Type.Category != models.CategoryImage { - c.Redirect(http.StatusFound, "/admin/settings/site") - return - } - logoDir := filepath.Join(storagePath, "logos") - os.MkdirAll(logoDir, 0755) - // 删除之前的本地 favicon(跳过外部 URL)。 - if s.Favicon != "" && !s.FaviconIsURL() { - os.Remove(filepath.Join(logoDir, s.Favicon)) - } - ext := strings.ToLower(filepath.Ext(header.Filename)) - savedName := fmt.Sprintf("favicon%s", ext) - dst, err := os.Create(filepath.Join(logoDir, savedName)) - if err != nil { - c.Redirect(http.StatusFound, "/admin/settings/site") - return - } - defer dst.Close() - if _, err := io.Copy(dst, file); err != nil { - c.Redirect(http.StatusFound, "/admin/settings/site") - return - } + logoDir := filepath.Join(storagePath, "logos") + os.MkdirAll(logoDir, 0755) + // 删除之前的本地文件(跳过外部 URL)。 + existing := s.Favicon + if prefix == "logo" { + existing = s.Logo + } + if existing != "" && (prefix == "logo" && !s.LogoIsURL() || prefix == "favicon" && !s.FaviconIsURL()) { + os.Remove(filepath.Join(logoDir, existing)) + } + ext := strings.ToLower(filepath.Ext(header.Filename)) + savedName := fmt.Sprintf("%s%s", prefix, ext) + dst, err := os.Create(filepath.Join(logoDir, savedName)) + if err != nil { + APIError(c, http.StatusInternalServerError, "api_error") + return + } + defer dst.Close() + if _, err := io.Copy(dst, file); err != nil { + APIError(c, http.StatusInternalServerError, "api_error") + return + } + + if prefix == "favicon" { s.Favicon = savedName - } - - // 若请求删除,则完全移除 favicon。 - if c.PostForm("favicon_clear") == "1" { - if s.Favicon != "" && !s.FaviconIsURL() { - os.Remove(filepath.Join(storagePath, "logos", s.Favicon)) - } - s.Favicon = "" - } - - // 徽标上传(可选)。logo_url 表单字段优先于上传文件, - // 因此管理员可以设置本地文件或外链。 - if logoURL := strings.TrimSpace(c.PostForm("logo_url")); logoURL != "" { - s.Logo = logoURL - } else if file, header, err := c.Request.FormFile("logo"); err == nil { - defer file.Close() - check := ValidateUpload(header) - if !check.OK || check.Type.Category != models.CategoryImage { - c.Redirect(http.StatusFound, "/admin/settings/site") - return - } - logoDir := filepath.Join(storagePath, "logos") - os.MkdirAll(logoDir, 0755) - // 删除之前的本地徽标(跳过外部 URL)。 - if s.Logo != "" && !s.LogoIsURL() { - os.Remove(filepath.Join(logoDir, s.Logo)) - } - ext := strings.ToLower(filepath.Ext(header.Filename)) - savedName := fmt.Sprintf("logo%s", ext) - dst, err := os.Create(filepath.Join(logoDir, savedName)) - if err != nil { - c.Redirect(http.StatusFound, "/admin/settings/site") - return - } - defer dst.Close() - if _, err := io.Copy(dst, file); err != nil { - c.Redirect(http.StatusFound, "/admin/settings/site") - return - } + } else { s.Logo = savedName } - - // 若请求删除,则完全移除徽标。 - if c.PostForm("logo_clear") == "1" { - if s.Logo != "" && !s.LogoIsURL() { - os.Remove(filepath.Join(storagePath, "logos", s.Logo)) - } - s.Logo = "" - } - if err := db.Save(&s).Error; err != nil { - c.Redirect(http.StatusFound, "/admin/settings/site") + APIError(c, http.StatusInternalServerError, "api_error") return } models.RefreshConfigCache(db) - c.Redirect(http.StatusFound, "/admin/settings/site?saved=1") + c.JSON(http.StatusOK, gin.H{ + "ok": true, + "name": savedName, + "redirect": "/admin/settings/site?saved=1", + }) } } // ---------------- 上传设置 ---------------- // fileTypeView 为 UploadFileType 附加预渲染的最大大小 MB 字符串供模板使用 -//(避免为除法引入模板 FuncMap)。 +// (避免为除法引入模板 FuncMap)。 type fileTypeView struct { models.UploadFileType MaxSizeMB string @@ -237,36 +285,59 @@ func UploadSettingsPage(db *gorm.DB) gin.HandlerFunc { } } +// uploadSettingsRequest 是 POST /api/admin/settings/upload 的 JSON 请求体。 +// action 分发:save_config / add_type / toggle_type / size_type / delete_type。 +// Enabled 为 nil 表示未提交(add_type 默认启用)。 +type uploadSettingsRequest struct { + Action string `json:"action"` + Enabled *bool `json:"enabled"` + DefaultMaxSize float64 `json:"default_max_size"` // MB + StorageDir string `json:"storage_dir"` + ID int `json:"id"` + Extension string `json:"extension"` + MimeType string `json:"mime_type"` + Category string `json:"category"` + MaxSize float64 `json:"max_size"` // MB +} + // UploadSettingsSave 分发上传配置与文件类型操作。 func UploadSettingsSave(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { - redirect := "/admin/settings/upload?saved=1" - switch c.PostForm("action") { + var req uploadSettingsRequest + if !bindJSON(c, &req) { + return + } + switch req.Action { case "save_config": - if !saveUploadConfig(db, c) { + if !saveUploadConfig(db, req, userIDFromSession(c)) { // SECURITY (#22):非法的 storage_dir 已被拒绝; - // 报告错误并保留原值。 - redirect = "/admin/settings/upload?error=illegal_dir" + // 保留原值并报告错误。 + APIError(c, http.StatusBadRequest, "settings_upload_illegal_dir") + return } case "add_type": - if addUploadFileType(db, c) { - redirect = "/admin/settings/upload?error=dangerous_ext" + if addUploadFileType(db, req) { + APIError(c, http.StatusBadRequest, "settings_upload_dangerous_ext") + return } case "toggle_type": - toggleUploadFileType(db, c) + toggleUploadFileType(db, req) case "size_type": - sizeUploadFileType(db, c) + sizeUploadFileType(db, req) case "delete_type": - deleteUploadFileType(db, c) + deleteUploadFileType(db, req) + default: + APIError(c, http.StatusBadRequest, "api_invalid_request") + return } models.RefreshConfigCache(db) - c.Redirect(http.StatusFound, redirect) + APIOK(c, "/admin/settings/upload?saved=1", nil) } } // safeStorageDirName 报告 s 是否为单一安全路径段:无分隔符、 // 无路径穿越、非绝对路径。storage_dir 必须保持在存储根目录内 -//(SECURITY_TODO #22)。 +// (SECURITY_TODO #22)。 func safeStorageDirName(s string) bool { if s == "" { return false @@ -282,25 +353,27 @@ func safeStorageDirName(s string) bool { } // saveUploadConfig 持久化上传策略。当提交的 storage_dir 不安全时 -//(SECURITY_TODO #22),返回 false 且不修改存储的值, +// (SECURITY_TODO #22),返回 false 且不修改存储的值, // 以免配置错误的管理员将附件写入存储根目录之外。 -func saveUploadConfig(db *gorm.DB, c *gin.Context) bool { +func saveUploadConfig(db *gorm.DB, req uploadSettingsRequest, updatedBy uint) bool { var u models.UploadConfig if err := db.First(&u, 1).Error; err != nil { u = models.UploadConfig{ID: 1} } - u.Enabled = c.PostForm("enabled") == "1" - u.DefaultMaxSize = mbToBytes(c.PostForm("default_max_size")) + if req.Enabled != nil { + u.Enabled = *req.Enabled + } + u.DefaultMaxSize = mbFromFloat(req.DefaultMaxSize) if u.DefaultMaxSize <= 0 { u.DefaultMaxSize = models.DefaultUploadMaxSize } - if dir := strings.TrimSpace(c.PostForm("storage_dir")); dir != "" { + if dir := strings.TrimSpace(req.StorageDir); dir != "" { if !safeStorageDirName(dir) { return false } u.StorageDir = dir } - u.UpdatedBy = userIDFromSession(c) + u.UpdatedBy = updatedBy db.Save(&u) return true } @@ -314,8 +387,8 @@ var dangerousUploadExtensions = map[string]bool{ } // addUploadFileType 创建新的允许文件类型。报告扩展名是否因危险而被拒绝。 -func addUploadFileType(db *gorm.DB, c *gin.Context) bool { - ext := strings.ToLower(strings.TrimSpace(c.PostForm("extension"))) +func addUploadFileType(db *gorm.DB, req uploadSettingsRequest) bool { + ext := strings.ToLower(strings.TrimSpace(req.Extension)) if ext == "" { return false } @@ -325,12 +398,16 @@ func addUploadFileType(db *gorm.DB, c *gin.Context) bool { if dangerousUploadExtensions[ext] { return true } + enabled := true // 未提交 Enabled 时默认启用(与旧表单语义一致) + if req.Enabled != nil { + enabled = *req.Enabled + } t := models.UploadFileType{ Extension: ext, - MimeType: strings.TrimSpace(c.PostForm("mime_type")), - Category: strings.TrimSpace(c.PostForm("category")), - MaxSize: mbToBytes(c.PostForm("max_size")), - Enabled: c.PostForm("enabled") != "0", + MimeType: strings.TrimSpace(req.MimeType), + Category: strings.TrimSpace(req.Category), + MaxSize: mbFromFloat(req.MaxSize), + Enabled: enabled, Sort: 50, } if t.Category == "" { @@ -341,29 +418,26 @@ func addUploadFileType(db *gorm.DB, c *gin.Context) bool { return false } -func toggleUploadFileType(db *gorm.DB, c *gin.Context) { - id, _ := strconv.Atoi(c.PostForm("id")) +func toggleUploadFileType(db *gorm.DB, req uploadSettingsRequest) { var t models.UploadFileType - if db.First(&t, id).Error != nil { + if db.First(&t, req.ID).Error != nil { return } t.Enabled = !t.Enabled db.Save(&t) } -func sizeUploadFileType(db *gorm.DB, c *gin.Context) { - id, _ := strconv.Atoi(c.PostForm("id")) +func sizeUploadFileType(db *gorm.DB, req uploadSettingsRequest) { var t models.UploadFileType - if db.First(&t, id).Error != nil { + if db.First(&t, req.ID).Error != nil { return } - t.MaxSize = mbToBytes(c.PostForm("max_size")) + t.MaxSize = mbFromFloat(req.MaxSize) db.Save(&t) } -func deleteUploadFileType(db *gorm.DB, c *gin.Context) { - id, _ := strconv.Atoi(c.PostForm("id")) - db.Delete(&models.UploadFileType{}, id) +func deleteUploadFileType(db *gorm.DB, req uploadSettingsRequest) { + db.Delete(&models.UploadFileType{}, req.ID) } // ---------------- 下载设置 ---------------- @@ -385,60 +459,77 @@ func DownloadSettingsPage(db *gorm.DB) gin.HandlerFunc { } } +// downloadSettingsRequest 是 POST /api/admin/settings/download 的 JSON 请求体。 +// action 分发:add / toggle / default / delete。 +type downloadSettingsRequest struct { + Action string `json:"action"` + Name string `json:"name"` + BaseURL string `json:"base_url"` + Priority int `json:"priority"` + Enabled *bool `json:"enabled"` + ID int `json:"id"` +} + // DownloadSettingsSave 分发下载基础 URL 操作。 func DownloadSettingsSave(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { - switch c.PostForm("action") { + var req downloadSettingsRequest + if !bindJSON(c, &req) { + return + } + switch req.Action { case "add": - addDownloadBaseURL(db, c) + addDownloadBaseURL(db, req) case "toggle": - toggleDownloadBaseURL(db, c) + toggleDownloadBaseURL(db, req) case "default": - defaultDownloadBaseURL(db, c) + defaultDownloadBaseURL(db, req) case "delete": - deleteDownloadBaseURL(db, c) + deleteDownloadBaseURL(db, req) + default: + APIError(c, http.StatusBadRequest, "api_invalid_request") + return } models.RefreshConfigCache(db) - c.Redirect(http.StatusFound, "/admin/settings/download?saved=1") + APIOK(c, "/admin/settings/download?saved=1", nil) } } -func addDownloadBaseURL(db *gorm.DB, c *gin.Context) { - name := strings.TrimSpace(c.PostForm("name")) - base := strings.TrimSpace(c.PostForm("base_url")) +func addDownloadBaseURL(db *gorm.DB, req downloadSettingsRequest) { + base := strings.TrimSpace(req.BaseURL) if base == "" { return } - prio, _ := strconv.Atoi(c.PostForm("priority")) + enabled := true // 未提交 Enabled 时默认启用 + if req.Enabled != nil { + enabled = *req.Enabled + } b := models.DownloadBaseURL{ - Name: name, + Name: strings.TrimSpace(req.Name), BaseURL: base, - Priority: prio, - Enabled: c.PostForm("enabled") != "0", + Priority: req.Priority, + Enabled: enabled, } db.Create(&b) } -func toggleDownloadBaseURL(db *gorm.DB, c *gin.Context) { - id, _ := strconv.Atoi(c.PostForm("id")) +func toggleDownloadBaseURL(db *gorm.DB, req downloadSettingsRequest) { var b models.DownloadBaseURL - if db.First(&b, id).Error != nil { + if db.First(&b, req.ID).Error != nil { return } b.Enabled = !b.Enabled db.Save(&b) } -func defaultDownloadBaseURL(db *gorm.DB, c *gin.Context) { - id, _ := strconv.Atoi(c.PostForm("id")) +func defaultDownloadBaseURL(db *gorm.DB, req downloadSettingsRequest) { // 同一时间只能有一个默认项。 db.Model(&models.DownloadBaseURL{}).Where("1=1").Update("is_default", false) - db.Model(&models.DownloadBaseURL{}).Where("id = ?", id).Update("is_default", true) + db.Model(&models.DownloadBaseURL{}).Where("id = ?", req.ID).Update("is_default", true) } -func deleteDownloadBaseURL(db *gorm.DB, c *gin.Context) { - id, _ := strconv.Atoi(c.PostForm("id")) - db.Delete(&models.DownloadBaseURL{}, id) +func deleteDownloadBaseURL(db *gorm.DB, req downloadSettingsRequest) { + db.Delete(&models.DownloadBaseURL{}, req.ID) } // ---------------- 评论设置 ---------------- @@ -462,22 +553,34 @@ func CommentSettingsPage(db *gorm.DB) gin.HandlerFunc { } } +// commentSettingsRequest 是 POST /api/admin/settings/comments 的 JSON 请求体。 +type commentSettingsRequest struct { + Enabled bool `json:"enabled"` + AllowGuest bool `json:"allow_guest"` + GuestRequireApproval bool `json:"guest_require_approval"` + UseGravatar bool `json:"use_gravatar"` +} + // CommentSettingsSave 持久化评论策略开关并刷新内存缓存, // 使后续请求能看到变更。 func CommentSettingsSave(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { + var req commentSettingsRequest + if !bindJSON(c, &req) { + return + } var cc models.CommentConfig if err := db.First(&cc, 1).Error; err != nil { cc = models.CommentConfig{ID: 1} } - cc.Enabled = c.PostForm("enabled") == "1" - cc.AllowGuest = c.PostForm("allow_guest") == "1" - cc.GuestRequireApproval = c.PostForm("guest_require_approval") == "1" - cc.UseGravatar = c.PostForm("use_gravatar") == "1" + cc.Enabled = req.Enabled + cc.AllowGuest = req.AllowGuest + cc.GuestRequireApproval = req.GuestRequireApproval + cc.UseGravatar = req.UseGravatar cc.UpdatedBy = userIDFromSession(c) db.Save(&cc) models.RefreshConfigCache(db) - c.Redirect(http.StatusFound, "/admin/settings/comments?saved=1") + APIOK(c, "/admin/settings/comments?saved=1", nil) } } @@ -500,68 +603,88 @@ func NavLinksSettingsPage(db *gorm.DB) gin.HandlerFunc { } } +// navLinkSettingsRequest 是 POST /api/admin/settings/navlinks 的 JSON 请求体。 +// action 分发:add / toggle / edit / delete。 +type navLinkSettingsRequest struct { + Action string `json:"action"` + ID int `json:"id"` + TitleZh string `json:"title_zh"` + TitleEn string `json:"title_en"` + URL string `json:"url"` + Sort int `json:"sort"` + OpenNew bool `json:"open_new"` + Enabled *bool `json:"enabled"` +} + // NavLinksSettingsSave 分发导航链接操作。 func NavLinksSettingsSave(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { - switch c.PostForm("action") { + var req navLinkSettingsRequest + if !bindJSON(c, &req) { + return + } + switch req.Action { case "add": - addNavLink(db, c) + addNavLink(db, req, userIDFromSession(c)) case "toggle": - toggleNavLink(db, c) + toggleNavLink(db, req, userIDFromSession(c)) case "edit": - editNavLink(db, c) + editNavLink(db, req, userIDFromSession(c)) case "delete": - deleteNavLink(db, c) + deleteNavLink(db, req) + default: + APIError(c, http.StatusBadRequest, "api_invalid_request") + return } models.RefreshConfigCache(db) - c.Redirect(http.StatusFound, "/admin/settings/navlinks?saved=1") + APIOK(c, "/admin/settings/navlinks?saved=1", nil) } } -func addNavLink(db *gorm.DB, c *gin.Context) { - titleZh := strings.TrimSpace(c.PostForm("title_zh")) - titleEn := strings.TrimSpace(c.PostForm("title_en")) - url := strings.TrimSpace(c.PostForm("url")) +func addNavLink(db *gorm.DB, req navLinkSettingsRequest, updatedBy uint) { + titleZh := strings.TrimSpace(req.TitleZh) + titleEn := strings.TrimSpace(req.TitleEn) + url := strings.TrimSpace(req.URL) if url == "" || (titleZh == "" && titleEn == "") { return } - sort, _ := strconv.Atoi(c.PostForm("sort")) - + enabled := true // 未提交 Enabled 时默认启用 + if req.Enabled != nil { + enabled = *req.Enabled + } link := models.NavLink{ - TitleZh: titleZh, - TitleEn: titleEn, - URL: url, - OpenNew: c.PostForm("open_new") == "1", - Enabled: c.PostForm("enabled") != "0", - Sort: sort, - UpdatedBy: userIDFromSession(c), + TitleZh: titleZh, + TitleEn: titleEn, + URL: url, + OpenNew: req.OpenNew, + Enabled: enabled, + Sort: req.Sort, + UpdatedBy: updatedBy, } db.Create(&link) } -func toggleNavLink(db *gorm.DB, c *gin.Context) { - id, _ := strconv.Atoi(c.PostForm("id")) +func toggleNavLink(db *gorm.DB, req navLinkSettingsRequest, updatedBy uint) { var link models.NavLink - if db.First(&link, id).Error != nil { + if db.First(&link, req.ID).Error != nil { return } link.Enabled = !link.Enabled - link.UpdatedBy = userIDFromSession(c) + link.UpdatedBy = updatedBy db.Save(&link) } -func editNavLink(db *gorm.DB, c *gin.Context) { - id, _ := strconv.Atoi(c.PostForm("id")) +func editNavLink(db *gorm.DB, req navLinkSettingsRequest, updatedBy uint) { var link models.NavLink - if db.First(&link, id).Error != nil { + if db.First(&link, req.ID).Error != nil { return } - titleZh := strings.TrimSpace(c.PostForm("title_zh")) - titleEn := strings.TrimSpace(c.PostForm("title_en")) - url := strings.TrimSpace(c.PostForm("url")) + titleZh := strings.TrimSpace(req.TitleZh) + titleEn := strings.TrimSpace(req.TitleEn) + url := strings.TrimSpace(req.URL) if url == "" || (titleZh == "" && titleEn == "") { return @@ -570,14 +693,12 @@ func editNavLink(db *gorm.DB, c *gin.Context) { link.TitleZh = titleZh link.TitleEn = titleEn link.URL = url - link.OpenNew = c.PostForm("open_new") == "1" - link.Sort, _ = strconv.Atoi(c.PostForm("sort")) - link.UpdatedBy = userIDFromSession(c) + link.OpenNew = req.OpenNew + link.Sort = req.Sort + link.UpdatedBy = updatedBy db.Save(&link) } -func deleteNavLink(db *gorm.DB, c *gin.Context) { - id, _ := strconv.Atoi(c.PostForm("id")) - db.Delete(&models.NavLink{}, id) +func deleteNavLink(db *gorm.DB, req navLinkSettingsRequest) { + db.Delete(&models.NavLink{}, req.ID) } - diff --git a/main.go b/main.go index 8c05180..5976286 100644 --- a/main.go +++ b/main.go @@ -241,15 +241,22 @@ func registerRoutes(router *gin.Engine, cfg *config.Config, db *gorm.DB, loginLi settings.Use(middleware.AuthRequired(db), middleware.AdminRequired(db)) { settings.GET("/site", handlers.SiteSettingsPage(db)) - settings.POST("/site", handlers.SiteSettingsSave(db, cfg.Path)) settings.GET("/navlinks", handlers.NavLinksSettingsPage(db)) - settings.POST("/navlinks", handlers.NavLinksSettingsSave(db)) settings.GET("/upload", handlers.UploadSettingsPage(db)) - settings.POST("/upload", handlers.UploadSettingsSave(db)) settings.GET("/download", handlers.DownloadSettingsPage(db)) - settings.POST("/download", handlers.DownloadSettingsSave(db)) settings.GET("/comments", handlers.CommentSettingsPage(db)) - settings.POST("/comments", handlers.CommentSettingsSave(db)) + } + + settingsAPI := router.Group("/api/admin/settings") + settingsAPI.Use(middleware.AuthRequired(db), middleware.AdminRequired(db)) + { + settingsAPI.POST("/site", handlers.SiteSettingsSave(db, cfg.Path)) + settingsAPI.POST("/site/favicon", handlers.SiteFaviconUpload(db, cfg.Path)) + settingsAPI.POST("/site/logo", handlers.SiteLogoUpload(db, cfg.Path)) + settingsAPI.POST("/navlinks", handlers.NavLinksSettingsSave(db)) + settingsAPI.POST("/upload", handlers.UploadSettingsSave(db)) + settingsAPI.POST("/download", handlers.DownloadSettingsSave(db)) + settingsAPI.POST("/comments", handlers.CommentSettingsSave(db)) } // 受保护的后台统计路由(读取统计信息)。 diff --git a/main_test.go b/main_test.go index bdf52a7..56ebdf6 100644 --- a/main_test.go +++ b/main_test.go @@ -162,6 +162,14 @@ func TestRegisterRoutesSmoke(t *testing.T) { "POST /api/admin/users": "", "PUT /api/admin/users/:id": "", "DELETE /api/admin/users/:id": "", + // 设置 API。 + "POST /api/admin/settings/site": "", + "POST /api/admin/settings/site/favicon": "", + "POST /api/admin/settings/site/logo": "", + "POST /api/admin/settings/navlinks": "", + "POST /api/admin/settings/upload": "", + "POST /api/admin/settings/download": "", + "POST /api/admin/settings/comments": "", // 搬移的附件/头像端点。 "POST /api/admin/articles/attachments": "", "DELETE /api/admin/articles/attachments/:id": "", diff --git a/templates/admin/settings_comment.html b/templates/admin/settings_comment.html index 3d9c03f..dbfce91 100644 --- a/templates/admin/settings_comment.html +++ b/templates/admin/settings_comment.html @@ -15,8 +15,8 @@
{{.Success}}
{{end}} -
- +