feat: 站点设置接口 JSON 化——/api/admin/settings/*

- 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 全绿
This commit is contained in:
2026-08-27 19:52:07 +08:00
parent 856d414295
commit efc30156b9
12 changed files with 450 additions and 283 deletions
+9 -15
View File
@@ -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 {
+3 -3
View File
@@ -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)。
+9 -20
View File
@@ -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)
+314 -193
View File
@@ -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 / SiteLogoUploadmultipart)。
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)
// faviconURL 设置优先;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
}
// logoURL 设置优先;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)
}
+12 -5
View File
@@ -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))
}
// 受保护的后台统计路由(读取统计信息)。
+8
View File
@@ -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": "",
+2 -2
View File
@@ -15,8 +15,8 @@
<div class="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-lg mb-6">{{.Success}}</div>
{{end}}
<form action="/admin/settings/comments" method="post" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<form class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4"
data-api-url="/api/admin/settings/comments" onsubmit="return blogSettingsForm(this)">
<label class="flex items-center gap-3 text-sm text-gray-700">
<input type="checkbox" name="enabled" value="1" {{if .CommentConfig.Enabled}}checked{{end}}
class="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500">
+8 -13
View File
@@ -41,22 +41,18 @@
{{else}}<span class="text-xs bg-gray-200 text-gray-500 px-2 py-1 rounded"></span>{{end}}
</td>
<td class="px-4 py-3 text-sm text-right whitespace-nowrap">
<form action="/admin/settings/download" method="post" class="inline">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<input type="hidden" name="action" value="default">
<form class="inline" data-api-url="/api/admin/settings/download"
data-action="default" onsubmit="return blogSettingsForm(this)">
<input type="hidden" name="id" value="{{.ID}}">
<button type="submit" class="text-blue-600 hover:text-blue-800 font-medium mr-3 cursor-pointer bg-transparent border-none">{{index $.Tr "settings_set_default"}}</button>
</form>
<form action="/admin/settings/download" method="post" class="inline">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<input type="hidden" name="action" value="toggle">
<form class="inline" data-api-url="/api/admin/settings/download"
data-action="toggle" onsubmit="return blogSettingsForm(this)">
<input type="hidden" name="id" value="{{.ID}}">
<button type="submit" class="text-gray-600 hover:text-gray-800 font-medium mr-3 cursor-pointer bg-transparent border-none">{{index $.Tr "settings_toggle"}}</button>
</form>
<form action="/admin/settings/download" method="post" class="inline"
onsubmit="return confirm('{{index $.Tr "article_delete_confirm"}}');">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<input type="hidden" name="action" value="delete">
<form class="inline" data-api-url="/api/admin/settings/download" data-action="delete"
data-confirm="{{index $.Tr "article_delete_confirm"}}" onsubmit="return blogSettingsForm(this)">
<input type="hidden" name="id" value="{{.ID}}">
<button type="submit" class="text-red-600 hover:text-red-800 font-medium cursor-pointer bg-transparent border-none">{{index $.Tr "settings_delete"}}</button>
</form>
@@ -72,9 +68,8 @@
</div>
<!-- Add base URL -->
<form action="/admin/settings/download" method="post" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<input type="hidden" name="action" value="add">
<form class="bg-white rounded-xl shadow-sm border border-gray-200 p-6"
data-api-url="/api/admin/settings/download" data-action="add" onsubmit="return blogSettingsForm(this)">
<h3 class="font-semibold text-gray-800 mb-4">{{index .Tr "settings_add_url"}}</h3>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4">
<div>
+6 -12
View File
@@ -16,9 +16,8 @@
{{end}}
<!-- Add New Link Form -->
<form action="/admin/settings/navlinks" method="post" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<input type="hidden" name="action" value="add">
<form class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6"
data-api-url="/api/admin/settings/navlinks" data-action="add" onsubmit="return blogSettingsForm(this)">
<h3 class="text-lg font-semibold text-gray-800 mb-4">{{index .Tr "navlinks_add"}}</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4">
@@ -91,9 +90,7 @@
</div>
<div class="flex items-center gap-2">
<!-- Toggle Button -->
<form action="/admin/settings/navlinks" method="post" class="inline">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<input type="hidden" name="action" value="toggle">
<form class="inline" data-api-url="/api/admin/settings/navlinks" data-action="toggle" onsubmit="return blogSettingsForm(this)">
<input type="hidden" name="id" value="{{.ID}}">
<button type="submit" class="text-sm px-3 py-1.5 rounded-lg border {{if .Enabled}}bg-green-50 border-green-300 text-green-700 hover:bg-green-100{{else}}bg-gray-100 border-gray-300 text-gray-600 hover:bg-gray-200{{end}} transition-colors">
{{if .Enabled}}✓{{else}}✗{{end}} {{index $.Tr "settings_toggle"}}
@@ -104,9 +101,8 @@
{{index $.Tr "navlinks_edit"}}
</button>
<!-- Delete Button -->
<form action="/admin/settings/navlinks" method="post" class="inline" onsubmit="return confirm('{{index $.Tr "navlinks_confirm_delete"}}')">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<input type="hidden" name="action" value="delete">
<form class="inline" data-api-url="/api/admin/settings/navlinks" data-action="delete"
data-confirm="{{index $.Tr "navlinks_confirm_delete"}}" onsubmit="return blogSettingsForm(this)">
<input type="hidden" name="id" value="{{.ID}}">
<button type="submit" class="text-sm px-3 py-1.5 bg-red-50 border border-red-300 text-red-700 rounded-lg hover:bg-red-100 transition-colors">
{{index $.Tr "navlinks_delete"}}
@@ -127,9 +123,7 @@
<div id="editModal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white rounded-xl shadow-xl max-w-2xl w-full mx-4 p-6">
<h3 class="text-xl font-semibold text-gray-900 mb-4">{{index .Tr "navlinks_edit"}}</h3>
<form action="/admin/settings/navlinks" method="post" id="editForm">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<input type="hidden" name="action" value="edit">
<form id="editForm" data-api-url="/api/admin/settings/navlinks" data-action="edit" onsubmit="return blogSettingsForm(this)">
<input type="hidden" name="id" id="edit_id">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4">
+48 -4
View File
@@ -15,8 +15,9 @@
<div class="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-lg mb-6">{{.Success}}</div>
{{end}}
<form action="/admin/settings/site" method="post" enctype="multipart/form-data" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-6">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<div id="siteSettingsError" class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6 hidden"></div>
<form id="siteSettingsForm" action="/api/admin/settings/site" method="post" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-6">
<!-- Logo -->
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">{{index .Tr "settings_logo"}}</label>
@@ -29,7 +30,7 @@
{{end}}
<input type="text" name="logo_url" placeholder="{{index .Tr "settings_logo_url"}}"
class="w-full border border-gray-300 rounded-lg px-3 py-2 mb-2" value="{{if .SiteLogoIsURL}}{{.Site.Logo}}{{end}}">
<input type="file" name="logo" accept="image/*"
<input type="file" id="logoFileInput" name="logo" accept="image/*"
class="block w-full text-sm text-gray-500 mb-2">
<label class="inline-flex items-center gap-2 text-sm text-gray-600">
<input type="checkbox" name="logo_clear" value="1"> {{index .Tr "settings_logo_clear"}}
@@ -51,7 +52,7 @@
{{end}}
<input type="text" name="favicon_url" placeholder="{{index .Tr "settings_favicon_url"}}"
class="w-full border border-gray-300 rounded-lg px-3 py-2 mb-2" value="{{if .SiteFaviconIsURL}}{{.Site.Favicon}}{{end}}">
<input type="file" name="favicon" accept="image/x-icon,image/png,image/svg+xml"
<input type="file" id="faviconFileInput" name="favicon" accept="image/x-icon,image/png,image/svg+xml"
class="block w-full text-sm text-gray-500 mb-2">
<p class="text-xs text-gray-500 mb-2">{{index .Tr "settings_favicon_hint"}}</p>
<label class="inline-flex items-center gap-2 text-sm text-gray-600">
@@ -156,4 +157,47 @@
</form>
</section>
{{template "footer" .}}
<script>
// 站点设置主表单:文本字段走 JSON API。
(function () {
var form = document.getElementById('siteSettingsForm');
if (!form) return;
form.addEventListener('submit', function (e) {
e.preventDefault();
var btn = e.submitter || null;
blogAPI('POST', '/api/admin/settings/site', blogForm(form, btn)).then(function (r) {
if (r.ok) { window.location.href = r.redirect || '/admin/settings/site'; }
else { blogShowError('siteSettingsError', r.error || 'Failed to save settings.'); }
});
});
})();
// logo / favicon 文件选择即上传(multipart)。
(function () {
function bindImageUpload(inputId, url, field) {
var input = document.getElementById(inputId);
if (!input) return;
input.addEventListener('change', function () {
var file = this.files[0];
if (!file) return;
var meta = document.querySelector('meta[name="csrf-token"]');
var fd = new FormData();
fd.append(field, file);
fetch(url, {
method: 'POST',
headers: { 'X-CSRF-Token': meta ? meta.getAttribute('content') : '' },
body: fd,
credentials: 'same-origin'
}).then(function (r) { return r.json(); })
.then(function (r) {
if (r.ok) { window.location.href = r.redirect || '/admin/settings/site'; }
else { blogShowError('siteSettingsError', r.error || 'Upload failed.'); }
});
});
}
bindImageUpload('logoFileInput', '/api/admin/settings/site/logo', 'logo');
bindImageUpload('faviconFileInput', '/api/admin/settings/site/favicon', 'favicon');
})();
</script>
{{end}}
+10 -16
View File
@@ -19,9 +19,8 @@
{{end}}
<!-- Global policy -->
<form action="/admin/settings/upload" method="post" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-8">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<input type="hidden" name="action" value="save_config">
<form class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-8"
data-api-url="/api/admin/settings/upload" data-action="save_config" onsubmit="return blogSettingsForm(this)">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4">
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">{{index .Tr "settings_default_size"}}</label>
@@ -69,9 +68,8 @@
<td class="px-4 py-3 text-sm text-gray-500">{{.MimeType}}</td>
<td class="px-4 py-3 text-sm text-gray-500">{{index $.Tr (printf "cat_%s" .Category)}}</td>
<td class="px-4 py-3 text-sm text-gray-500">
<form action="/admin/settings/upload" method="post" class="flex items-center gap-1">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<input type="hidden" name="action" value="size_type">
<form class="flex items-center gap-1" data-api-url="/api/admin/settings/upload"
data-action="size_type" onsubmit="return blogSettingsForm(this)">
<input type="hidden" name="id" value="{{.ID}}">
<input type="number" name="max_size" min="0" step="0.1" value="{{.MaxSizeMB}}"
class="w-20 border border-gray-300 rounded px-2 py-1 text-sm">
@@ -83,16 +81,13 @@
{{else}}<span class="text-xs bg-gray-200 text-gray-500 px-2 py-1 rounded"></span>{{end}}
</td>
<td class="px-4 py-3 text-sm text-right whitespace-nowrap">
<form action="/admin/settings/upload" method="post" class="inline">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<input type="hidden" name="action" value="toggle_type">
<form class="inline" data-api-url="/api/admin/settings/upload"
data-action="toggle_type" onsubmit="return blogSettingsForm(this)">
<input type="hidden" name="id" value="{{.ID}}">
<button type="submit" class="text-gray-600 hover:text-gray-800 font-medium mr-3 cursor-pointer bg-transparent border-none">{{index $.Tr "settings_toggle"}}</button>
</form>
<form action="/admin/settings/upload" method="post" class="inline"
onsubmit="return confirm('{{index $.Tr "article_delete_confirm"}}');">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<input type="hidden" name="action" value="delete_type">
<form class="inline" data-api-url="/api/admin/settings/upload" data-action="delete_type"
data-confirm="{{index $.Tr "article_delete_confirm"}}" onsubmit="return blogSettingsForm(this)">
<input type="hidden" name="id" value="{{.ID}}">
<button type="submit" class="text-red-600 hover:text-red-800 font-medium cursor-pointer bg-transparent border-none">{{index $.Tr "settings_delete"}}</button>
</form>
@@ -104,9 +99,8 @@
</div>
<!-- Add file type -->
<form action="/admin/settings/upload" method="post" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<input type="hidden" name="action" value="add_type">
<form class="bg-white rounded-xl shadow-sm border border-gray-200 p-6"
data-api-url="/api/admin/settings/upload" data-action="add_type" onsubmit="return blogSettingsForm(this)">
<h3 class="font-semibold text-gray-800 mb-4">{{index .Tr "settings_add_type"}}</h3>
<div class="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-4">
<div>
+21
View File
@@ -202,6 +202,7 @@
// 将表单序列化为 JSON 数据对象:
// - 文本/select/textarea 从 FormData 取值(checkboxes 在下述循环覆盖)
// - checkbox 一律转 bool(未选中也发送 false,匹配服务端 JSON 绑定)
// - file 字段剔除(文件上传走单独的 multipart 端点)
// - 提交按钮(如文章表单的草稿/发布 name=status)取自 submitter
window.blogForm = function (form, submitter) {
var data = {};
@@ -209,6 +210,9 @@
if (k === '_csrf') return;
if (!(k in data)) data[k] = v;
});
Array.prototype.forEach.call(form.querySelectorAll('input[type="file"]'), function (el) {
if (el.name) { data[el.name] = undefined; delete data[el.name]; }
});
Array.prototype.forEach.call(form.querySelectorAll('input[type="checkbox"]'), function (el) {
if (el.name) data[el.name] = el.checked;
});
@@ -248,6 +252,23 @@
return false;
};
// 设置页小表单委托(onsubmit = "return blogSettingsForm(this)"):
// POST data-api-urlbody 为 {action: data-action} + 表单序列化字段,
// 成功后跳转 data.redirect(通常刷新列表),失败 alert 文案。
window.blogSettingsForm = function (form) {
var url = form.getAttribute('data-api-url');
var confirmText = form.getAttribute('data-confirm');
if (confirmText && !confirm(confirmText)) return false;
var data = blogForm(form, null);
var action = form.getAttribute('data-action');
if (action) data.action = action;
blogAPI('POST', url, data).then(function (r) {
if (r.ok) { window.location.href = r.redirect || window.location.href; }
else { alert(r.error || 'Failed'); }
});
return false;
};
function toggleDropdown() {
var menu = document.getElementById('dropdownMenu');
menu.classList.toggle('hidden');