- 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 全绿
705 lines
20 KiB
Go
705 lines
20 KiB
Go
package handlers
|
||
|
||
import (
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"github.com/gin-contrib/sessions"
|
||
"github.com/gin-gonic/gin"
|
||
"gorm.io/gorm"
|
||
|
||
"go_blog/models"
|
||
)
|
||
|
||
// mbToBytes 将兆字节数(字符串)转换为字节。解析失败时返回 0。
|
||
// <= 0 的值视为 0(对按类型限制来说表示"使用默认值")。
|
||
func mbToBytes(s string) int64 {
|
||
s = strings.TrimSpace(s)
|
||
if s == "" {
|
||
return 0
|
||
}
|
||
n, err := strconv.ParseFloat(s, 64)
|
||
if err != nil {
|
||
return 0
|
||
}
|
||
return int64(n * 1024 * 1024)
|
||
}
|
||
|
||
// bytesToMB 将字节数渲染为兆字节(一位小数)用于表单展示。
|
||
func bytesToMB(b int64) string {
|
||
return fmt.Sprintf("%.1f", float64(b)/float64(1024*1024))
|
||
}
|
||
|
||
// userIDFromSession 提取已登录用户的 ID,不存在时为 0。
|
||
// 它兼容会话中 int/uint/int64/float64 的存储类型。
|
||
func userIDFromSession(c *gin.Context) uint {
|
||
session := sessions.Default(c)
|
||
userID := session.Get("user_id")
|
||
if userID == nil {
|
||
return 0
|
||
}
|
||
switch v := userID.(type) {
|
||
case uint:
|
||
return v
|
||
case int:
|
||
return uint(v)
|
||
case int64:
|
||
return uint(v)
|
||
case float64:
|
||
return uint(v)
|
||
}
|
||
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 渲染站点显示设置表单。
|
||
func SiteSettingsPage(db *gorm.DB) gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
tr := getTr(c)
|
||
var s models.SiteSetting
|
||
if err := db.First(&s, 1).Error; err != nil {
|
||
s = models.SiteSetting{ID: 1}
|
||
}
|
||
|
||
data := DefaultData(c)
|
||
data["Title"] = tr["settings_site_title"]
|
||
data["Site"] = s
|
||
// 预计算派生值,使模板绝不调用包装为 interface{} 的结构体上的方法
|
||
//(Go 模板无法解析此类调用)。
|
||
data["SiteLogoIsURL"] = s.LogoIsURL()
|
||
data["SiteFaviconIsURL"] = s.FaviconIsURL()
|
||
if msg := c.Query("saved"); msg == "1" {
|
||
data["Success"] = tr["settings_saved"]
|
||
}
|
||
c.HTML(http.StatusOK, "settings_site", data)
|
||
}
|
||
}
|
||
|
||
// 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}
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
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
|
||
} else {
|
||
s.Logo = savedName
|
||
}
|
||
if err := db.Save(&s).Error; err != nil {
|
||
APIError(c, http.StatusInternalServerError, "api_error")
|
||
return
|
||
}
|
||
models.RefreshConfigCache(db)
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"ok": true,
|
||
"name": savedName,
|
||
"redirect": "/admin/settings/site?saved=1",
|
||
})
|
||
}
|
||
}
|
||
|
||
// ---------------- 上传设置 ----------------
|
||
|
||
// fileTypeView 为 UploadFileType 附加预渲染的最大大小 MB 字符串供模板使用
|
||
// (避免为除法引入模板 FuncMap)。
|
||
type fileTypeView struct {
|
||
models.UploadFileType
|
||
MaxSizeMB string
|
||
}
|
||
|
||
// UploadSettingsPage 渲染上传策略 + 文件类型管理页面。
|
||
func UploadSettingsPage(db *gorm.DB) gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
tr := getTr(c)
|
||
var u models.UploadConfig
|
||
if err := db.First(&u, 1).Error; err != nil {
|
||
u = models.UploadConfig{ID: 1, Enabled: true, DefaultMaxSize: models.DefaultUploadMaxSize, StorageDir: "attachments"}
|
||
}
|
||
var types []models.UploadFileType
|
||
db.Order("category asc, sort asc, id asc").Find(&types)
|
||
|
||
views := make([]fileTypeView, 0, len(types))
|
||
for _, t := range types {
|
||
views = append(views, fileTypeView{UploadFileType: t, MaxSizeMB: bytesToMB(t.MaxSize)})
|
||
}
|
||
|
||
data := DefaultData(c)
|
||
data["Title"] = tr["settings_upload_title"]
|
||
data["Upload"] = u
|
||
data["FileTypes"] = views
|
||
data["DefaultMaxSizeMB"] = bytesToMB(u.DefaultMaxSize)
|
||
if msg := c.Query("saved"); msg == "1" {
|
||
data["Success"] = tr["settings_saved"]
|
||
}
|
||
if msg := c.Query("error"); msg == "dangerous_ext" {
|
||
data["Error"] = tr["settings_upload_dangerous_ext"]
|
||
}
|
||
if c.Query("error") == "illegal_dir" {
|
||
data["Error"] = tr["settings_upload_illegal_dir"]
|
||
}
|
||
c.HTML(http.StatusOK, "settings_upload", data)
|
||
}
|
||
}
|
||
|
||
// 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) {
|
||
var req uploadSettingsRequest
|
||
if !bindJSON(c, &req) {
|
||
return
|
||
}
|
||
switch req.Action {
|
||
case "save_config":
|
||
if !saveUploadConfig(db, req, userIDFromSession(c)) {
|
||
// SECURITY (#22):非法的 storage_dir 已被拒绝;
|
||
// 保留原值并报告错误。
|
||
APIError(c, http.StatusBadRequest, "settings_upload_illegal_dir")
|
||
return
|
||
}
|
||
case "add_type":
|
||
if addUploadFileType(db, req) {
|
||
APIError(c, http.StatusBadRequest, "settings_upload_dangerous_ext")
|
||
return
|
||
}
|
||
case "toggle_type":
|
||
toggleUploadFileType(db, req)
|
||
case "size_type":
|
||
sizeUploadFileType(db, req)
|
||
case "delete_type":
|
||
deleteUploadFileType(db, req)
|
||
default:
|
||
APIError(c, http.StatusBadRequest, "api_invalid_request")
|
||
return
|
||
}
|
||
models.RefreshConfigCache(db)
|
||
APIOK(c, "/admin/settings/upload?saved=1", nil)
|
||
}
|
||
}
|
||
|
||
// safeStorageDirName 报告 s 是否为单一安全路径段:无分隔符、
|
||
// 无路径穿越、非绝对路径。storage_dir 必须保持在存储根目录内
|
||
// (SECURITY_TODO #22)。
|
||
func safeStorageDirName(s string) bool {
|
||
if s == "" {
|
||
return false
|
||
}
|
||
for _, r := range s {
|
||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
|
||
(r >= '0' && r <= '9') || r == '_' || r == '-' {
|
||
continue
|
||
}
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
// saveUploadConfig 持久化上传策略。当提交的 storage_dir 不安全时
|
||
// (SECURITY_TODO #22),返回 false 且不修改存储的值,
|
||
// 以免配置错误的管理员将附件写入存储根目录之外。
|
||
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}
|
||
}
|
||
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(req.StorageDir); dir != "" {
|
||
if !safeStorageDirName(dir) {
|
||
return false
|
||
}
|
||
u.StorageDir = dir
|
||
}
|
||
u.UpdatedBy = updatedBy
|
||
db.Save(&u)
|
||
return true
|
||
}
|
||
|
||
// dangerousUploadExtensions 永不接受为上传文件类型:这些扩展名的文件将从
|
||
// /uploads 同源提供,可在站点源上执行活动内容(HTML/SVG/JS),
|
||
// 使任何已登录的上传者获得存储型 XSS 能力(SECURITY_TODO #21)。
|
||
var dangerousUploadExtensions = map[string]bool{
|
||
".html": true, ".htm": true, ".xhtml": true, ".xht": true,
|
||
".svg": true, ".xml": true, ".js": true, ".mjs": true,
|
||
}
|
||
|
||
// addUploadFileType 创建新的允许文件类型。报告扩展名是否因危险而被拒绝。
|
||
func addUploadFileType(db *gorm.DB, req uploadSettingsRequest) bool {
|
||
ext := strings.ToLower(strings.TrimSpace(req.Extension))
|
||
if ext == "" {
|
||
return false
|
||
}
|
||
if !strings.HasPrefix(ext, ".") {
|
||
ext = "." + ext
|
||
}
|
||
if dangerousUploadExtensions[ext] {
|
||
return true
|
||
}
|
||
enabled := true // 未提交 Enabled 时默认启用(与旧表单语义一致)
|
||
if req.Enabled != nil {
|
||
enabled = *req.Enabled
|
||
}
|
||
t := models.UploadFileType{
|
||
Extension: ext,
|
||
MimeType: strings.TrimSpace(req.MimeType),
|
||
Category: strings.TrimSpace(req.Category),
|
||
MaxSize: mbFromFloat(req.MaxSize),
|
||
Enabled: enabled,
|
||
Sort: 50,
|
||
}
|
||
if t.Category == "" {
|
||
t.Category = models.CategoryOther
|
||
}
|
||
// 静默忽略扩展名重复的错误。
|
||
db.Where("extension = ?", t.Extension).FirstOrCreate(&t)
|
||
return false
|
||
}
|
||
|
||
func toggleUploadFileType(db *gorm.DB, req uploadSettingsRequest) {
|
||
var t models.UploadFileType
|
||
if db.First(&t, req.ID).Error != nil {
|
||
return
|
||
}
|
||
t.Enabled = !t.Enabled
|
||
db.Save(&t)
|
||
}
|
||
|
||
func sizeUploadFileType(db *gorm.DB, req uploadSettingsRequest) {
|
||
var t models.UploadFileType
|
||
if db.First(&t, req.ID).Error != nil {
|
||
return
|
||
}
|
||
t.MaxSize = mbFromFloat(req.MaxSize)
|
||
db.Save(&t)
|
||
}
|
||
|
||
func deleteUploadFileType(db *gorm.DB, req uploadSettingsRequest) {
|
||
db.Delete(&models.UploadFileType{}, req.ID)
|
||
}
|
||
|
||
// ---------------- 下载设置 ----------------
|
||
|
||
// DownloadSettingsPage 渲染下载基础 URL 管理页面。
|
||
func DownloadSettingsPage(db *gorm.DB) gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
tr := getTr(c)
|
||
var urls []models.DownloadBaseURL
|
||
db.Order("is_default desc, priority asc, id asc").Find(&urls)
|
||
|
||
data := DefaultData(c)
|
||
data["Title"] = tr["settings_download_title"]
|
||
data["BaseURLs"] = urls
|
||
if msg := c.Query("saved"); msg == "1" {
|
||
data["Success"] = tr["settings_saved"]
|
||
}
|
||
c.HTML(http.StatusOK, "settings_download", data)
|
||
}
|
||
}
|
||
|
||
// 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) {
|
||
var req downloadSettingsRequest
|
||
if !bindJSON(c, &req) {
|
||
return
|
||
}
|
||
switch req.Action {
|
||
case "add":
|
||
addDownloadBaseURL(db, req)
|
||
case "toggle":
|
||
toggleDownloadBaseURL(db, req)
|
||
case "default":
|
||
defaultDownloadBaseURL(db, req)
|
||
case "delete":
|
||
deleteDownloadBaseURL(db, req)
|
||
default:
|
||
APIError(c, http.StatusBadRequest, "api_invalid_request")
|
||
return
|
||
}
|
||
models.RefreshConfigCache(db)
|
||
APIOK(c, "/admin/settings/download?saved=1", nil)
|
||
}
|
||
}
|
||
|
||
func addDownloadBaseURL(db *gorm.DB, req downloadSettingsRequest) {
|
||
base := strings.TrimSpace(req.BaseURL)
|
||
if base == "" {
|
||
return
|
||
}
|
||
enabled := true // 未提交 Enabled 时默认启用
|
||
if req.Enabled != nil {
|
||
enabled = *req.Enabled
|
||
}
|
||
b := models.DownloadBaseURL{
|
||
Name: strings.TrimSpace(req.Name),
|
||
BaseURL: base,
|
||
Priority: req.Priority,
|
||
Enabled: enabled,
|
||
}
|
||
db.Create(&b)
|
||
}
|
||
|
||
func toggleDownloadBaseURL(db *gorm.DB, req downloadSettingsRequest) {
|
||
var b models.DownloadBaseURL
|
||
if db.First(&b, req.ID).Error != nil {
|
||
return
|
||
}
|
||
b.Enabled = !b.Enabled
|
||
db.Save(&b)
|
||
}
|
||
|
||
func defaultDownloadBaseURL(db *gorm.DB, req downloadSettingsRequest) {
|
||
// 同一时间只能有一个默认项。
|
||
db.Model(&models.DownloadBaseURL{}).Where("1=1").Update("is_default", false)
|
||
db.Model(&models.DownloadBaseURL{}).Where("id = ?", req.ID).Update("is_default", true)
|
||
}
|
||
|
||
func deleteDownloadBaseURL(db *gorm.DB, req downloadSettingsRequest) {
|
||
db.Delete(&models.DownloadBaseURL{}, req.ID)
|
||
}
|
||
|
||
// ---------------- 评论设置 ----------------
|
||
|
||
// CommentSettingsPage 渲染评论策略表单。
|
||
func CommentSettingsPage(db *gorm.DB) gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
tr := getTr(c)
|
||
var cc models.CommentConfig
|
||
if err := db.First(&cc, 1).Error; err != nil {
|
||
cc = *models.GetCommentConfig()
|
||
}
|
||
|
||
data := DefaultData(c)
|
||
data["Title"] = tr["comment_settings_title"]
|
||
data["CommentConfig"] = cc
|
||
if msg := c.Query("saved"); msg == "1" {
|
||
data["Success"] = tr["settings_saved"]
|
||
}
|
||
c.HTML(http.StatusOK, "settings_comment", data)
|
||
}
|
||
}
|
||
|
||
// 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 = req.Enabled
|
||
cc.AllowGuest = req.AllowGuest
|
||
cc.GuestRequireApproval = req.GuestRequireApproval
|
||
cc.UseGravatar = req.UseGravatar
|
||
cc.UpdatedBy = userIDFromSession(c)
|
||
db.Save(&cc)
|
||
models.RefreshConfigCache(db)
|
||
APIOK(c, "/admin/settings/comments?saved=1", nil)
|
||
}
|
||
}
|
||
|
||
// ---------------- 导航链接设置 ----------------
|
||
|
||
// NavLinksSettingsPage 渲染导航链接管理页面。
|
||
func NavLinksSettingsPage(db *gorm.DB) gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
tr := getTr(c)
|
||
var links []models.NavLink
|
||
db.Order("sort asc, id asc").Find(&links)
|
||
|
||
data := DefaultData(c)
|
||
data["Title"] = tr["settings_navlinks_title"]
|
||
data["NavLinks"] = links
|
||
if msg := c.Query("saved"); msg == "1" {
|
||
data["Success"] = tr["settings_saved"]
|
||
}
|
||
c.HTML(http.StatusOK, "settings_navlinks", data)
|
||
}
|
||
}
|
||
|
||
// 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) {
|
||
var req navLinkSettingsRequest
|
||
if !bindJSON(c, &req) {
|
||
return
|
||
}
|
||
switch req.Action {
|
||
case "add":
|
||
addNavLink(db, req, userIDFromSession(c))
|
||
case "toggle":
|
||
toggleNavLink(db, req, userIDFromSession(c))
|
||
case "edit":
|
||
editNavLink(db, req, userIDFromSession(c))
|
||
case "delete":
|
||
deleteNavLink(db, req)
|
||
default:
|
||
APIError(c, http.StatusBadRequest, "api_invalid_request")
|
||
return
|
||
}
|
||
models.RefreshConfigCache(db)
|
||
APIOK(c, "/admin/settings/navlinks?saved=1", nil)
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
enabled := true // 未提交 Enabled 时默认启用
|
||
if req.Enabled != nil {
|
||
enabled = *req.Enabled
|
||
}
|
||
link := models.NavLink{
|
||
TitleZh: titleZh,
|
||
TitleEn: titleEn,
|
||
URL: url,
|
||
OpenNew: req.OpenNew,
|
||
Enabled: enabled,
|
||
Sort: req.Sort,
|
||
UpdatedBy: updatedBy,
|
||
}
|
||
db.Create(&link)
|
||
}
|
||
|
||
func toggleNavLink(db *gorm.DB, req navLinkSettingsRequest, updatedBy uint) {
|
||
var link models.NavLink
|
||
if db.First(&link, req.ID).Error != nil {
|
||
return
|
||
}
|
||
link.Enabled = !link.Enabled
|
||
link.UpdatedBy = updatedBy
|
||
db.Save(&link)
|
||
}
|
||
|
||
func editNavLink(db *gorm.DB, req navLinkSettingsRequest, updatedBy uint) {
|
||
var link models.NavLink
|
||
if db.First(&link, req.ID).Error != nil {
|
||
return
|
||
}
|
||
|
||
titleZh := strings.TrimSpace(req.TitleZh)
|
||
titleEn := strings.TrimSpace(req.TitleEn)
|
||
url := strings.TrimSpace(req.URL)
|
||
|
||
if url == "" || (titleZh == "" && titleEn == "") {
|
||
return
|
||
}
|
||
|
||
link.TitleZh = titleZh
|
||
link.TitleEn = titleEn
|
||
link.URL = url
|
||
link.OpenNew = req.OpenNew
|
||
link.Sort = req.Sort
|
||
link.UpdatedBy = updatedBy
|
||
db.Save(&link)
|
||
}
|
||
|
||
func deleteNavLink(db *gorm.DB, req navLinkSettingsRequest) {
|
||
db.Delete(&models.NavLink{}, req.ID)
|
||
}
|