Files
go_blog/handlers/settings.go
T
kevin f307781f58 docs: 全部 Go 代码注释汉化
- 48 个 Go 文件所有注释(行注释/块注释/行尾注释,含 _test.go)翻译为中文
- 保留技术标识符:SECURITY_TODO(n)、unsafe-inline、sqlite/mysql、路由参数等
- 代码、字符串字面量、日志消息保持英文原文,零逻辑改动
- go build/vet 通过,go test -count=1 ./... 全绿
2026-08-27 19:03:03 +08:00

584 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
// ---------------- 站点设置 ----------------
// 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 处理站点设置的徽标上传与文本字段。
func SiteSettingsSave(db *gorm.DB, storagePath 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)
// 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
}
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
}
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")
return
}
models.RefreshConfigCache(db)
c.Redirect(http.StatusFound, "/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)
}
}
// UploadSettingsSave 分发上传配置与文件类型操作。
func UploadSettingsSave(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
redirect := "/admin/settings/upload?saved=1"
switch c.PostForm("action") {
case "save_config":
if !saveUploadConfig(db, c) {
// SECURITY (#22):非法的 storage_dir 已被拒绝;
// 报告错误并保留原值。
redirect = "/admin/settings/upload?error=illegal_dir"
}
case "add_type":
if addUploadFileType(db, c) {
redirect = "/admin/settings/upload?error=dangerous_ext"
}
case "toggle_type":
toggleUploadFileType(db, c)
case "size_type":
sizeUploadFileType(db, c)
case "delete_type":
deleteUploadFileType(db, c)
}
models.RefreshConfigCache(db)
c.Redirect(http.StatusFound, redirect)
}
}
// 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, c *gin.Context) 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 u.DefaultMaxSize <= 0 {
u.DefaultMaxSize = models.DefaultUploadMaxSize
}
if dir := strings.TrimSpace(c.PostForm("storage_dir")); dir != "" {
if !safeStorageDirName(dir) {
return false
}
u.StorageDir = dir
}
u.UpdatedBy = userIDFromSession(c)
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, c *gin.Context) bool {
ext := strings.ToLower(strings.TrimSpace(c.PostForm("extension")))
if ext == "" {
return false
}
if !strings.HasPrefix(ext, ".") {
ext = "." + ext
}
if dangerousUploadExtensions[ext] {
return true
}
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",
Sort: 50,
}
if t.Category == "" {
t.Category = models.CategoryOther
}
// 静默忽略扩展名重复的错误。
db.Where("extension = ?", t.Extension).FirstOrCreate(&t)
return false
}
func toggleUploadFileType(db *gorm.DB, c *gin.Context) {
id, _ := strconv.Atoi(c.PostForm("id"))
var t models.UploadFileType
if db.First(&t, 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"))
var t models.UploadFileType
if db.First(&t, id).Error != nil {
return
}
t.MaxSize = mbToBytes(c.PostForm("max_size"))
db.Save(&t)
}
func deleteUploadFileType(db *gorm.DB, c *gin.Context) {
id, _ := strconv.Atoi(c.PostForm("id"))
db.Delete(&models.UploadFileType{}, 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)
}
}
// DownloadSettingsSave 分发下载基础 URL 操作。
func DownloadSettingsSave(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
switch c.PostForm("action") {
case "add":
addDownloadBaseURL(db, c)
case "toggle":
toggleDownloadBaseURL(db, c)
case "default":
defaultDownloadBaseURL(db, c)
case "delete":
deleteDownloadBaseURL(db, c)
}
models.RefreshConfigCache(db)
c.Redirect(http.StatusFound, "/admin/settings/download?saved=1")
}
}
func addDownloadBaseURL(db *gorm.DB, c *gin.Context) {
name := strings.TrimSpace(c.PostForm("name"))
base := strings.TrimSpace(c.PostForm("base_url"))
if base == "" {
return
}
prio, _ := strconv.Atoi(c.PostForm("priority"))
b := models.DownloadBaseURL{
Name: name,
BaseURL: base,
Priority: prio,
Enabled: c.PostForm("enabled") != "0",
}
db.Create(&b)
}
func toggleDownloadBaseURL(db *gorm.DB, c *gin.Context) {
id, _ := strconv.Atoi(c.PostForm("id"))
var b models.DownloadBaseURL
if db.First(&b, 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"))
// 同一时间只能有一个默认项。
db.Model(&models.DownloadBaseURL{}).Where("1=1").Update("is_default", false)
db.Model(&models.DownloadBaseURL{}).Where("id = ?", id).Update("is_default", true)
}
func deleteDownloadBaseURL(db *gorm.DB, c *gin.Context) {
id, _ := strconv.Atoi(c.PostForm("id"))
db.Delete(&models.DownloadBaseURL{}, 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)
}
}
// CommentSettingsSave 持久化评论策略开关并刷新内存缓存,
// 使后续请求能看到变更。
func CommentSettingsSave(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
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.UpdatedBy = userIDFromSession(c)
db.Save(&cc)
models.RefreshConfigCache(db)
c.Redirect(http.StatusFound, "/admin/settings/comments?saved=1")
}
}
// ---------------- 导航链接设置 ----------------
// 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)
}
}
// NavLinksSettingsSave 分发导航链接操作。
func NavLinksSettingsSave(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
switch c.PostForm("action") {
case "add":
addNavLink(db, c)
case "toggle":
toggleNavLink(db, c)
case "edit":
editNavLink(db, c)
case "delete":
deleteNavLink(db, c)
}
models.RefreshConfigCache(db)
c.Redirect(http.StatusFound, "/admin/settings/navlinks?saved=1")
}
}
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"))
if url == "" || (titleZh == "" && titleEn == "") {
return
}
sort, _ := strconv.Atoi(c.PostForm("sort"))
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),
}
db.Create(&link)
}
func toggleNavLink(db *gorm.DB, c *gin.Context) {
id, _ := strconv.Atoi(c.PostForm("id"))
var link models.NavLink
if db.First(&link, id).Error != nil {
return
}
link.Enabled = !link.Enabled
link.UpdatedBy = userIDFromSession(c)
db.Save(&link)
}
func editNavLink(db *gorm.DB, c *gin.Context) {
id, _ := strconv.Atoi(c.PostForm("id"))
var link models.NavLink
if db.First(&link, id).Error != nil {
return
}
titleZh := strings.TrimSpace(c.PostForm("title_zh"))
titleEn := strings.TrimSpace(c.PostForm("title_en"))
url := strings.TrimSpace(c.PostForm("url"))
if url == "" || (titleZh == "" && titleEn == "") {
return
}
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)
db.Save(&link)
}
func deleteNavLink(db *gorm.DB, c *gin.Context) {
id, _ := strconv.Atoi(c.PostForm("id"))
db.Delete(&models.NavLink{}, id)
}