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:
@@ -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 {
|
||||
|
||||
@@ -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,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
@@ -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 / 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}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
// 受保护的后台统计路由(读取统计信息)。
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}}
|
||||
@@ -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>
|
||||
|
||||
@@ -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-url,body 为 {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');
|
||||
|
||||
Reference in New Issue
Block a user