Files
go_blog/handlers/settings.go
T
kevin e314b05670 fix: 修复 P1 复审漏洞 #20 #21(禁用用户会话失效 + 头像 XSS 链)
- #20 AuthRequired 改为 AuthRequired(db):受保护路由每次回库校验
  Status == StatusNormal 且未软删,失败清 session(保留 lang/csrf_token,
  与登录轮换口径一致)并 302 /login;session user_id 先断言为数值再入
  GORM(呼应 #19),AdminRequired 同步加固;SetUserContext 仅在用户
  存在且状态正常时置 is_logged_in——禁用用户发评论不再自动通过,
  回落游客审核策略
- #21 头像两个分支(UploadAvatar / UpdateProfile)强制
  Category == image,解码失败直接拒绝、删除"回退存原始字节"路径,
  统一经 processAvatar 解码→256px 缩放→JPEG 重编码;addUploadFileType
  增加危险扩展黑名单(.html/.htm/.xhtml/.xht/.svg/.xml/.js/.mjs),
  拒绝添加并在上传设置页提示(模板 + 中英 i18n)
- 附带修复:processAvatar 依赖的 png/gif 解码器此前未注册(旧代码靠
  回退存原始字节掩盖,PNG 头像从未真正处理过),补 blank import
- 新增回归测试 session_upload_security_test.go(6 用例:禁用/锁定/
  软删旧 cookie 302、锁定用户评论转 pending、6 组危险扩展拒绝、
  伪装扩展名头像拒绝且磁盘零写入、正常图片转存 .jpg;已变异验证:
  去掉任一修复对应测试即失败)
- SECURITY_TODO.md 勾选 #20/#21 并更新执行顺序
2026-08-27 17:35:46 +08:00

553 lines
16 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 converts a megabyte count (string) to bytes. Returns 0 on parse
// failure. Values <= 0 are treated as 0 (meaning "use default" for per-type
// limits).
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 renders a byte count as megabytes (one decimal) for form display.
func bytesToMB(b int64) string {
return fmt.Sprintf("%.1f", float64(b)/float64(1024*1024))
}
// userIDFromSession extracts the logged-in user's ID, or 0 if absent. It
// defends against int/uint/int64/float64 storage in the session.
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
}
// ---------------- Site settings ----------------
// SiteSettingsPage renders the site display settings form.
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
// Pre-compute derived values so the template never invokes methods on
// an interface{}-wrapped struct (which Go templates cannot resolve).
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 handles logo upload and text fields for site settings.
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"))
s.AllowRegistration = c.PostForm("allow_registration") == "1"
s.UpdatedBy = userIDFromSession(c)
// Favicon upload (optional). A favicon_url form field takes precedence over an
// uploaded file, so admins can set either a local file or an external link.
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)
// Remove the previous local favicon (skip external URLs).
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
}
// Remove favicon entirely if requested.
if c.PostForm("favicon_clear") == "1" {
if s.Favicon != "" && !s.FaviconIsURL() {
os.Remove(filepath.Join(storagePath, "logos", s.Favicon))
}
s.Favicon = ""
}
// Logo upload (optional). A logo_url form field takes precedence over an
// uploaded file, so admins can set either a local file or an external link.
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)
// Remove the previous local logo (skip external URLs).
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
}
// Remove logo entirely if requested.
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")
}
}
// ---------------- Upload settings ----------------
// fileTypeView augments an UploadFileType with a pre-rendered max-size MB
// string for the template (avoids needing a template FuncMap for division).
type fileTypeView struct {
models.UploadFileType
MaxSizeMB string
}
// UploadSettingsPage renders the upload policy + file-type management page.
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"]
}
c.HTML(http.StatusOK, "settings_upload", data)
}
}
// UploadSettingsSave dispatches upload-config and file-type actions.
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":
saveUploadConfig(db, c)
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)
}
}
func saveUploadConfig(db *gorm.DB, c *gin.Context) {
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 != "" {
u.StorageDir = dir
}
u.UpdatedBy = userIDFromSession(c)
db.Save(&u)
}
// dangerousUploadExtensions are never accepted as upload file types: files of
// these extensions would be served same-origin from /uploads and can execute
// active content (HTML/SVG/JS) in the site's origin, giving any logged-in
// uploader a stored-XSS primitive (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 creates a new permitted file type. It reports whether the
// extension was rejected as dangerous.
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
}
// Ignore duplicate-extension errors silently.
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)
}
// ---------------- Download settings ----------------
// DownloadSettingsPage renders the download base-URL management page.
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 dispatches download base-URL actions.
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"))
// Only one default at a time.
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)
}
// ---------------- Comment settings ----------------
// CommentSettingsPage renders the comment policy form.
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 persists the comment policy toggles and refreshes the
// in-memory cache so subsequent requests see the change.
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")
}
}
// ---------------- Navigation Links settings ----------------
// NavLinksSettingsPage renders the navigation links management page.
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 dispatches navigation link actions.
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)
}