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) }