Files
go_blog/handlers/settings.go
T
kevin efc30156b9 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 全绿
2026-08-27 19:52:07 +08:00

705 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handlers
import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"go_blog/models"
)
// mbToBytes 将兆字节数(字符串)转换为字节。解析失败时返回 0。
// <= 0 的值视为 0(对按类型限制来说表示"使用默认值")。
func mbToBytes(s string) int64 {
s = strings.TrimSpace(s)
if s == "" {
return 0
}
n, err := strconv.ParseFloat(s, 64)
if err != nil {
return 0
}
return int64(n * 1024 * 1024)
}
// bytesToMB 将字节数渲染为兆字节(一位小数)用于表单展示。
func bytesToMB(b int64) string {
return fmt.Sprintf("%.1f", float64(b)/float64(1024*1024))
}
// userIDFromSession 提取已登录用户的 ID,不存在时为 0。
// 它兼容会话中 int/uint/int64/float64 的存储类型。
func userIDFromSession(c *gin.Context) uint {
session := sessions.Default(c)
userID := session.Get("user_id")
if userID == nil {
return 0
}
switch v := userID.(type) {
case uint:
return v
case int:
return uint(v)
case int64:
return uint(v)
case float64:
return uint(v)
}
return 0
}
// mbFromFloat 将兆字节数(浮点,来自 JSON)转换为字节。
func mbFromFloat(v float64) int64 {
if v <= 0 {
return 0
}
return int64(v * 1024 * 1024)
}
// siteSettingsRequest 是 POST /api/admin/settings/site 的 JSON 请求体。
// 文本字段为空即存储为空;favicon/logo 的清除与 URL 设置通过专用字段。
type siteSettingsRequest struct {
LogoTextZh string `json:"logo_text_zh"`
LogoTextEn string `json:"logo_text_en"`
HeaderTextZh string `json:"header_text_zh"`
HeaderTextEn string `json:"header_text_en"`
HomeWelcomeZh string `json:"home_welcome_zh"`
HomeWelcomeEn string `json:"home_welcome_en"`
HomeSubtitleZh string `json:"home_subtitle_zh"`
HomeSubtitleEn string `json:"home_subtitle_en"`
FooterTextZh string `json:"footer_text_zh"`
FooterTextEn string `json:"footer_text_en"`
SiteURL string `json:"site_url"`
AllowRegistration bool `json:"allow_registration"`
FaviconURL string `json:"favicon_url"`
FaviconClear bool `json:"favicon_clear"`
LogoURL string `json:"logo_url"`
LogoClear bool `json:"logo_clear"`
}
// ---------------- 站点设置 ----------------
// SiteSettingsPage 渲染站点显示设置表单。
func SiteSettingsPage(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
tr := getTr(c)
var s models.SiteSetting
if err := db.First(&s, 1).Error; err != nil {
s = models.SiteSetting{ID: 1}
}
data := DefaultData(c)
data["Title"] = tr["settings_site_title"]
data["Site"] = s
// 预计算派生值,使模板绝不调用包装为 interface{} 的结构体上的方法
//Go 模板无法解析此类调用)。
data["SiteLogoIsURL"] = s.LogoIsURL()
data["SiteFaviconIsURL"] = s.FaviconIsURL()
if msg := c.Query("saved"); msg == "1" {
data["Success"] = tr["settings_saved"]
}
c.HTML(http.StatusOK, "settings_site", data)
}
}
// SiteSettingsSave 处理站点设置的文本字段。
// favicon/logo 文件上传走 SiteFaviconUpload / 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}
}
file, header, err := c.Request.FormFile(fieldName)
if err != nil {
APIError(c, http.StatusBadRequest, "api_invalid_request")
return
}
defer file.Close()
check := ValidateUpload(header)
if !check.OK || check.Type.Category != models.CategoryImage {
APIError(c, http.StatusBadRequest, "settings_upload_dangerous_ext")
return
}
logoDir := filepath.Join(storagePath, "logos")
os.MkdirAll(logoDir, 0755)
// 删除之前的本地文件(跳过外部 URL)。
existing := s.Favicon
if prefix == "logo" {
existing = s.Logo
}
if existing != "" && (prefix == "logo" && !s.LogoIsURL() || prefix == "favicon" && !s.FaviconIsURL()) {
os.Remove(filepath.Join(logoDir, existing))
}
ext := strings.ToLower(filepath.Ext(header.Filename))
savedName := fmt.Sprintf("%s%s", prefix, ext)
dst, err := os.Create(filepath.Join(logoDir, savedName))
if err != nil {
APIError(c, http.StatusInternalServerError, "api_error")
return
}
defer dst.Close()
if _, err := io.Copy(dst, file); err != nil {
APIError(c, http.StatusInternalServerError, "api_error")
return
}
if prefix == "favicon" {
s.Favicon = savedName
} else {
s.Logo = savedName
}
if err := db.Save(&s).Error; err != nil {
APIError(c, http.StatusInternalServerError, "api_error")
return
}
models.RefreshConfigCache(db)
c.JSON(http.StatusOK, gin.H{
"ok": true,
"name": savedName,
"redirect": "/admin/settings/site?saved=1",
})
}
}
// ---------------- 上传设置 ----------------
// fileTypeView 为 UploadFileType 附加预渲染的最大大小 MB 字符串供模板使用
// (避免为除法引入模板 FuncMap)。
type fileTypeView struct {
models.UploadFileType
MaxSizeMB string
}
// UploadSettingsPage 渲染上传策略 + 文件类型管理页面。
func UploadSettingsPage(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
tr := getTr(c)
var u models.UploadConfig
if err := db.First(&u, 1).Error; err != nil {
u = models.UploadConfig{ID: 1, Enabled: true, DefaultMaxSize: models.DefaultUploadMaxSize, StorageDir: "attachments"}
}
var types []models.UploadFileType
db.Order("category asc, sort asc, id asc").Find(&types)
views := make([]fileTypeView, 0, len(types))
for _, t := range types {
views = append(views, fileTypeView{UploadFileType: t, MaxSizeMB: bytesToMB(t.MaxSize)})
}
data := DefaultData(c)
data["Title"] = tr["settings_upload_title"]
data["Upload"] = u
data["FileTypes"] = views
data["DefaultMaxSizeMB"] = bytesToMB(u.DefaultMaxSize)
if msg := c.Query("saved"); msg == "1" {
data["Success"] = tr["settings_saved"]
}
if msg := c.Query("error"); msg == "dangerous_ext" {
data["Error"] = tr["settings_upload_dangerous_ext"]
}
if c.Query("error") == "illegal_dir" {
data["Error"] = tr["settings_upload_illegal_dir"]
}
c.HTML(http.StatusOK, "settings_upload", data)
}
}
// uploadSettingsRequest 是 POST /api/admin/settings/upload 的 JSON 请求体。
// action 分发:save_config / add_type / toggle_type / size_type / delete_type。
// Enabled 为 nil 表示未提交(add_type 默认启用)。
type uploadSettingsRequest struct {
Action string `json:"action"`
Enabled *bool `json:"enabled"`
DefaultMaxSize float64 `json:"default_max_size"` // MB
StorageDir string `json:"storage_dir"`
ID int `json:"id"`
Extension string `json:"extension"`
MimeType string `json:"mime_type"`
Category string `json:"category"`
MaxSize float64 `json:"max_size"` // MB
}
// UploadSettingsSave 分发上传配置与文件类型操作。
func UploadSettingsSave(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
var req uploadSettingsRequest
if !bindJSON(c, &req) {
return
}
switch req.Action {
case "save_config":
if !saveUploadConfig(db, req, userIDFromSession(c)) {
// SECURITY (#22):非法的 storage_dir 已被拒绝;
// 保留原值并报告错误。
APIError(c, http.StatusBadRequest, "settings_upload_illegal_dir")
return
}
case "add_type":
if addUploadFileType(db, req) {
APIError(c, http.StatusBadRequest, "settings_upload_dangerous_ext")
return
}
case "toggle_type":
toggleUploadFileType(db, req)
case "size_type":
sizeUploadFileType(db, req)
case "delete_type":
deleteUploadFileType(db, req)
default:
APIError(c, http.StatusBadRequest, "api_invalid_request")
return
}
models.RefreshConfigCache(db)
APIOK(c, "/admin/settings/upload?saved=1", nil)
}
}
// safeStorageDirName 报告 s 是否为单一安全路径段:无分隔符、
// 无路径穿越、非绝对路径。storage_dir 必须保持在存储根目录内
// SECURITY_TODO #22)。
func safeStorageDirName(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
(r >= '0' && r <= '9') || r == '_' || r == '-' {
continue
}
return false
}
return true
}
// saveUploadConfig 持久化上传策略。当提交的 storage_dir 不安全时
// SECURITY_TODO #22),返回 false 且不修改存储的值,
// 以免配置错误的管理员将附件写入存储根目录之外。
func saveUploadConfig(db *gorm.DB, req uploadSettingsRequest, updatedBy uint) bool {
var u models.UploadConfig
if err := db.First(&u, 1).Error; err != nil {
u = models.UploadConfig{ID: 1}
}
if req.Enabled != nil {
u.Enabled = *req.Enabled
}
u.DefaultMaxSize = mbFromFloat(req.DefaultMaxSize)
if u.DefaultMaxSize <= 0 {
u.DefaultMaxSize = models.DefaultUploadMaxSize
}
if dir := strings.TrimSpace(req.StorageDir); dir != "" {
if !safeStorageDirName(dir) {
return false
}
u.StorageDir = dir
}
u.UpdatedBy = updatedBy
db.Save(&u)
return true
}
// dangerousUploadExtensions 永不接受为上传文件类型:这些扩展名的文件将从
// /uploads 同源提供,可在站点源上执行活动内容(HTML/SVG/JS),
// 使任何已登录的上传者获得存储型 XSS 能力(SECURITY_TODO #21)。
var dangerousUploadExtensions = map[string]bool{
".html": true, ".htm": true, ".xhtml": true, ".xht": true,
".svg": true, ".xml": true, ".js": true, ".mjs": true,
}
// addUploadFileType 创建新的允许文件类型。报告扩展名是否因危险而被拒绝。
func addUploadFileType(db *gorm.DB, req uploadSettingsRequest) bool {
ext := strings.ToLower(strings.TrimSpace(req.Extension))
if ext == "" {
return false
}
if !strings.HasPrefix(ext, ".") {
ext = "." + ext
}
if dangerousUploadExtensions[ext] {
return true
}
enabled := true // 未提交 Enabled 时默认启用(与旧表单语义一致)
if req.Enabled != nil {
enabled = *req.Enabled
}
t := models.UploadFileType{
Extension: ext,
MimeType: strings.TrimSpace(req.MimeType),
Category: strings.TrimSpace(req.Category),
MaxSize: mbFromFloat(req.MaxSize),
Enabled: enabled,
Sort: 50,
}
if t.Category == "" {
t.Category = models.CategoryOther
}
// 静默忽略扩展名重复的错误。
db.Where("extension = ?", t.Extension).FirstOrCreate(&t)
return false
}
func toggleUploadFileType(db *gorm.DB, req uploadSettingsRequest) {
var t models.UploadFileType
if db.First(&t, req.ID).Error != nil {
return
}
t.Enabled = !t.Enabled
db.Save(&t)
}
func sizeUploadFileType(db *gorm.DB, req uploadSettingsRequest) {
var t models.UploadFileType
if db.First(&t, req.ID).Error != nil {
return
}
t.MaxSize = mbFromFloat(req.MaxSize)
db.Save(&t)
}
func deleteUploadFileType(db *gorm.DB, req uploadSettingsRequest) {
db.Delete(&models.UploadFileType{}, req.ID)
}
// ---------------- 下载设置 ----------------
// DownloadSettingsPage 渲染下载基础 URL 管理页面。
func DownloadSettingsPage(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
tr := getTr(c)
var urls []models.DownloadBaseURL
db.Order("is_default desc, priority asc, id asc").Find(&urls)
data := DefaultData(c)
data["Title"] = tr["settings_download_title"]
data["BaseURLs"] = urls
if msg := c.Query("saved"); msg == "1" {
data["Success"] = tr["settings_saved"]
}
c.HTML(http.StatusOK, "settings_download", data)
}
}
// downloadSettingsRequest 是 POST /api/admin/settings/download 的 JSON 请求体。
// action 分发:add / toggle / default / delete。
type downloadSettingsRequest struct {
Action string `json:"action"`
Name string `json:"name"`
BaseURL string `json:"base_url"`
Priority int `json:"priority"`
Enabled *bool `json:"enabled"`
ID int `json:"id"`
}
// DownloadSettingsSave 分发下载基础 URL 操作。
func DownloadSettingsSave(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
var req downloadSettingsRequest
if !bindJSON(c, &req) {
return
}
switch req.Action {
case "add":
addDownloadBaseURL(db, req)
case "toggle":
toggleDownloadBaseURL(db, req)
case "default":
defaultDownloadBaseURL(db, req)
case "delete":
deleteDownloadBaseURL(db, req)
default:
APIError(c, http.StatusBadRequest, "api_invalid_request")
return
}
models.RefreshConfigCache(db)
APIOK(c, "/admin/settings/download?saved=1", nil)
}
}
func addDownloadBaseURL(db *gorm.DB, req downloadSettingsRequest) {
base := strings.TrimSpace(req.BaseURL)
if base == "" {
return
}
enabled := true // 未提交 Enabled 时默认启用
if req.Enabled != nil {
enabled = *req.Enabled
}
b := models.DownloadBaseURL{
Name: strings.TrimSpace(req.Name),
BaseURL: base,
Priority: req.Priority,
Enabled: enabled,
}
db.Create(&b)
}
func toggleDownloadBaseURL(db *gorm.DB, req downloadSettingsRequest) {
var b models.DownloadBaseURL
if db.First(&b, req.ID).Error != nil {
return
}
b.Enabled = !b.Enabled
db.Save(&b)
}
func defaultDownloadBaseURL(db *gorm.DB, req downloadSettingsRequest) {
// 同一时间只能有一个默认项。
db.Model(&models.DownloadBaseURL{}).Where("1=1").Update("is_default", false)
db.Model(&models.DownloadBaseURL{}).Where("id = ?", req.ID).Update("is_default", true)
}
func deleteDownloadBaseURL(db *gorm.DB, req downloadSettingsRequest) {
db.Delete(&models.DownloadBaseURL{}, req.ID)
}
// ---------------- 评论设置 ----------------
// CommentSettingsPage 渲染评论策略表单。
func CommentSettingsPage(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
tr := getTr(c)
var cc models.CommentConfig
if err := db.First(&cc, 1).Error; err != nil {
cc = *models.GetCommentConfig()
}
data := DefaultData(c)
data["Title"] = tr["comment_settings_title"]
data["CommentConfig"] = cc
if msg := c.Query("saved"); msg == "1" {
data["Success"] = tr["settings_saved"]
}
c.HTML(http.StatusOK, "settings_comment", data)
}
}
// commentSettingsRequest 是 POST /api/admin/settings/comments 的 JSON 请求体。
type commentSettingsRequest struct {
Enabled bool `json:"enabled"`
AllowGuest bool `json:"allow_guest"`
GuestRequireApproval bool `json:"guest_require_approval"`
UseGravatar bool `json:"use_gravatar"`
}
// CommentSettingsSave 持久化评论策略开关并刷新内存缓存,
// 使后续请求能看到变更。
func CommentSettingsSave(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
var req commentSettingsRequest
if !bindJSON(c, &req) {
return
}
var cc models.CommentConfig
if err := db.First(&cc, 1).Error; err != nil {
cc = models.CommentConfig{ID: 1}
}
cc.Enabled = req.Enabled
cc.AllowGuest = req.AllowGuest
cc.GuestRequireApproval = req.GuestRequireApproval
cc.UseGravatar = req.UseGravatar
cc.UpdatedBy = userIDFromSession(c)
db.Save(&cc)
models.RefreshConfigCache(db)
APIOK(c, "/admin/settings/comments?saved=1", nil)
}
}
// ---------------- 导航链接设置 ----------------
// NavLinksSettingsPage 渲染导航链接管理页面。
func NavLinksSettingsPage(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
tr := getTr(c)
var links []models.NavLink
db.Order("sort asc, id asc").Find(&links)
data := DefaultData(c)
data["Title"] = tr["settings_navlinks_title"]
data["NavLinks"] = links
if msg := c.Query("saved"); msg == "1" {
data["Success"] = tr["settings_saved"]
}
c.HTML(http.StatusOK, "settings_navlinks", data)
}
}
// navLinkSettingsRequest 是 POST /api/admin/settings/navlinks 的 JSON 请求体。
// action 分发:add / toggle / edit / delete。
type navLinkSettingsRequest struct {
Action string `json:"action"`
ID int `json:"id"`
TitleZh string `json:"title_zh"`
TitleEn string `json:"title_en"`
URL string `json:"url"`
Sort int `json:"sort"`
OpenNew bool `json:"open_new"`
Enabled *bool `json:"enabled"`
}
// NavLinksSettingsSave 分发导航链接操作。
func NavLinksSettingsSave(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
var req navLinkSettingsRequest
if !bindJSON(c, &req) {
return
}
switch req.Action {
case "add":
addNavLink(db, req, userIDFromSession(c))
case "toggle":
toggleNavLink(db, req, userIDFromSession(c))
case "edit":
editNavLink(db, req, userIDFromSession(c))
case "delete":
deleteNavLink(db, req)
default:
APIError(c, http.StatusBadRequest, "api_invalid_request")
return
}
models.RefreshConfigCache(db)
APIOK(c, "/admin/settings/navlinks?saved=1", nil)
}
}
func addNavLink(db *gorm.DB, req navLinkSettingsRequest, updatedBy uint) {
titleZh := strings.TrimSpace(req.TitleZh)
titleEn := strings.TrimSpace(req.TitleEn)
url := strings.TrimSpace(req.URL)
if url == "" || (titleZh == "" && titleEn == "") {
return
}
enabled := true // 未提交 Enabled 时默认启用
if req.Enabled != nil {
enabled = *req.Enabled
}
link := models.NavLink{
TitleZh: titleZh,
TitleEn: titleEn,
URL: url,
OpenNew: req.OpenNew,
Enabled: enabled,
Sort: req.Sort,
UpdatedBy: updatedBy,
}
db.Create(&link)
}
func toggleNavLink(db *gorm.DB, req navLinkSettingsRequest, updatedBy uint) {
var link models.NavLink
if db.First(&link, req.ID).Error != nil {
return
}
link.Enabled = !link.Enabled
link.UpdatedBy = updatedBy
db.Save(&link)
}
func editNavLink(db *gorm.DB, req navLinkSettingsRequest, updatedBy uint) {
var link models.NavLink
if db.First(&link, req.ID).Error != nil {
return
}
titleZh := strings.TrimSpace(req.TitleZh)
titleEn := strings.TrimSpace(req.TitleEn)
url := strings.TrimSpace(req.URL)
if url == "" || (titleZh == "" && titleEn == "") {
return
}
link.TitleZh = titleZh
link.TitleEn = titleEn
link.URL = url
link.OpenNew = req.OpenNew
link.Sort = req.Sort
link.UpdatedBy = updatedBy
db.Save(&link)
}
func deleteNavLink(db *gorm.DB, req navLinkSettingsRequest) {
db.Delete(&models.NavLink{}, req.ID)
}