feat: add platform configuration tables and admin settings
Add four DB-backed configuration tables for site-wide settings: site_settings (logo, top-left title, header/footer text with zh/en variants), upload_configs (master switch, default size, storage dir), upload_file_types (per-extension whitelist with per-type size limits), and download_baseurls (multiple download sources with priority/default). - Models, AutoMigrate, and first-run seed (18 common file types) - Process-level config cache warmed at startup, refreshed on admin save - SetUserContext injects cached site settings into templates - base.html renders logo (local upload or external URL), title, header banner, and footer with i18n fallback - Upload validator drives avatar uploads (form + AJAX) through the configured switch/type/size policy - Admin pages for site/upload/download settings with i18n keys Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+20
-8
@@ -16,16 +16,28 @@ func DefaultData(c *gin.Context) gin.H {
|
||||
avatar, _ := c.Get("avatar")
|
||||
displayName, _ := c.Get("display_name")
|
||||
role, _ := c.Get("role")
|
||||
siteSetting, _ := c.Get("site_setting")
|
||||
siteLogo, _ := c.Get("site_logo")
|
||||
siteLogoIsURL, _ := c.Get("site_logo_is_url")
|
||||
siteLogoText, _ := c.Get("site_logo_text")
|
||||
siteHeaderText, _ := c.Get("site_header_text")
|
||||
siteFooterText, _ := c.Get("site_footer_text")
|
||||
|
||||
return gin.H{
|
||||
"Tr": tr,
|
||||
"IsLoggedIn": isLoggedIn,
|
||||
"Username": username,
|
||||
"Lang": lang,
|
||||
"SwitchLang": switchLang,
|
||||
"Avatar": avatar,
|
||||
"DisplayName": displayName,
|
||||
"Role": role,
|
||||
"Tr": tr,
|
||||
"IsLoggedIn": isLoggedIn,
|
||||
"Username": username,
|
||||
"Lang": lang,
|
||||
"SwitchLang": switchLang,
|
||||
"Avatar": avatar,
|
||||
"DisplayName": displayName,
|
||||
"Role": role,
|
||||
"SiteSetting": siteSetting,
|
||||
"SiteLogo": siteLogo,
|
||||
"SiteLogoIsURL": siteLogoIsURL,
|
||||
"SiteLogoText": siteLogoText,
|
||||
"SiteHeaderText": siteHeaderText,
|
||||
"SiteFooterText": siteFooterText,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+39
-8
@@ -46,6 +46,14 @@ func ProfilePage(db *gorm.DB) gin.HandlerFunc {
|
||||
if msg := c.Query("error"); msg == "pw" {
|
||||
data["Error"] = tr["profile_wrong_password"]
|
||||
}
|
||||
switch c.Query("error") {
|
||||
case "upload":
|
||||
data["Error"] = tr["profile_upload_invalid"]
|
||||
case "upload_disabled":
|
||||
data["Error"] = tr["profile_upload_disabled"]
|
||||
case "size":
|
||||
data["Error"] = fmt.Sprintf(tr["profile_upload_too_large"], c.Query("max"))
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "profile", data)
|
||||
}
|
||||
@@ -84,12 +92,23 @@ func UpdateProfile(db *gorm.DB, storagePath string) gin.HandlerFunc {
|
||||
if err == nil {
|
||||
defer file.Close()
|
||||
|
||||
// Determine file extension.
|
||||
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||
if ext == "" || (ext != ".jpg" && ext != ".jpeg" && ext != ".png" && ext != ".gif" && ext != ".webp") {
|
||||
ext = ".jpg"
|
||||
// Validate against the platform upload policy (switch + type + size).
|
||||
check := ValidateUpload(header)
|
||||
if !check.OK {
|
||||
session.Save()
|
||||
reason := "?error=upload"
|
||||
if !models.GetUploadConfig().Enabled {
|
||||
reason = "?error=upload_disabled"
|
||||
} else if check.Type != nil {
|
||||
reason = fmt.Sprintf("?error=size&max=%s", formatSize(check.MaxSize))
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/profile"+reason)
|
||||
return
|
||||
}
|
||||
|
||||
// Determine file extension (validated to be in the whitelist).
|
||||
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||
|
||||
// Save under storagePath/avatars/.
|
||||
avatarDir := filepath.Join(storagePath, "avatars")
|
||||
if err := os.MkdirAll(avatarDir, 0755); err != nil {
|
||||
@@ -173,12 +192,24 @@ func UploadAvatar(db *gorm.DB, storagePath string) gin.HandlerFunc {
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Validate extension.
|
||||
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||
if ext == "" || (ext != ".jpg" && ext != ".jpeg" && ext != ".png" && ext != ".gif" && ext != ".webp") {
|
||||
ext = ".jpg"
|
||||
// Validate against the platform upload policy (switch + type + size).
|
||||
check := ValidateUpload(header)
|
||||
if !check.OK {
|
||||
if !models.GetUploadConfig().Enabled {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "uploads are disabled"})
|
||||
return
|
||||
}
|
||||
if check.Type != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("file too large; limit is %s", formatSize(check.MaxSize))})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "file type not allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
// Determine file extension (validated to be in the whitelist).
|
||||
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||
|
||||
// Read file bytes for image processing.
|
||||
imgBytes, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go_blog/models"
|
||||
)
|
||||
|
||||
// mbToBytes converts a megabyte count (string) to bytes. Returns 0 on parse
|
||||
// failure. Values <= 0 are treated as 0 (meaning "use default" for per-type
|
||||
// limits).
|
||||
func mbToBytes(s string) int64 {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
n, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return int64(n * 1024 * 1024)
|
||||
}
|
||||
|
||||
// bytesToMB renders a byte count as megabytes (one decimal) for form display.
|
||||
func bytesToMB(b int64) string {
|
||||
return fmt.Sprintf("%.1f", float64(b)/float64(1024*1024))
|
||||
}
|
||||
|
||||
// userIDFromSession extracts the logged-in user's ID, or 0 if absent.
|
||||
func userIDFromSession(c *gin.Context) uint {
|
||||
session := sessions.Default(c)
|
||||
if id, ok := session.Get("user_id").(uint); ok {
|
||||
return id
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ---------------- Site settings ----------------
|
||||
|
||||
// SiteSettingsPage renders the site display settings form.
|
||||
func SiteSettingsPage(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
var s models.SiteSetting
|
||||
if err := db.First(&s, 1).Error; err != nil {
|
||||
s = models.SiteSetting{ID: 1}
|
||||
}
|
||||
|
||||
data := DefaultData(c)
|
||||
data["Title"] = tr["settings_site_title"]
|
||||
data["Site"] = s
|
||||
if msg := c.Query("saved"); msg == "1" {
|
||||
data["Success"] = tr["settings_saved"]
|
||||
}
|
||||
c.HTML(http.StatusOK, "settings_site", data)
|
||||
}
|
||||
}
|
||||
|
||||
// SiteSettingsSave handles logo upload and text fields for site settings.
|
||||
func SiteSettingsSave(db *gorm.DB, storagePath string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var s models.SiteSetting
|
||||
if err := db.First(&s, 1).Error; err != nil {
|
||||
s = models.SiteSetting{ID: 1}
|
||||
}
|
||||
|
||||
s.LogoTextZh = strings.TrimSpace(c.PostForm("logo_text_zh"))
|
||||
s.LogoTextEn = strings.TrimSpace(c.PostForm("logo_text_en"))
|
||||
s.HeaderTextZh = strings.TrimSpace(c.PostForm("header_text_zh"))
|
||||
s.HeaderTextEn = strings.TrimSpace(c.PostForm("header_text_en"))
|
||||
s.FooterTextZh = strings.TrimSpace(c.PostForm("footer_text_zh"))
|
||||
s.FooterTextEn = strings.TrimSpace(c.PostForm("footer_text_en"))
|
||||
s.UpdatedBy = userIDFromSession(c)
|
||||
|
||||
// Logo upload (optional). A logo_url form field takes precedence over an
|
||||
// uploaded file, so admins can set either a local file or an external link.
|
||||
if logoURL := strings.TrimSpace(c.PostForm("logo_url")); logoURL != "" {
|
||||
s.Logo = logoURL
|
||||
} else if file, header, err := c.Request.FormFile("logo"); err == nil {
|
||||
defer file.Close()
|
||||
check := ValidateUpload(header)
|
||||
if !check.OK || check.Type.Category != models.CategoryImage {
|
||||
c.Redirect(http.StatusFound, "/admin/settings/site")
|
||||
return
|
||||
}
|
||||
logoDir := filepath.Join(storagePath, "logos")
|
||||
os.MkdirAll(logoDir, 0755)
|
||||
// Remove the previous local logo (skip external URLs).
|
||||
if s.Logo != "" && !s.LogoIsURL() {
|
||||
os.Remove(filepath.Join(logoDir, s.Logo))
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||
savedName := fmt.Sprintf("logo%s", ext)
|
||||
dst, err := os.Create(filepath.Join(logoDir, savedName))
|
||||
if err != nil {
|
||||
c.Redirect(http.StatusFound, "/admin/settings/site")
|
||||
return
|
||||
}
|
||||
defer dst.Close()
|
||||
if _, err := io.Copy(dst, file); err != nil {
|
||||
c.Redirect(http.StatusFound, "/admin/settings/site")
|
||||
return
|
||||
}
|
||||
s.Logo = savedName
|
||||
}
|
||||
|
||||
// Remove logo entirely if requested.
|
||||
if c.PostForm("logo_clear") == "1" {
|
||||
if s.Logo != "" && !s.LogoIsURL() {
|
||||
os.Remove(filepath.Join(storagePath, "logos", s.Logo))
|
||||
}
|
||||
s.Logo = ""
|
||||
}
|
||||
|
||||
if err := db.Save(&s).Error; err != nil {
|
||||
c.Redirect(http.StatusFound, "/admin/settings/site")
|
||||
return
|
||||
}
|
||||
models.RefreshConfigCache(db)
|
||||
c.Redirect(http.StatusFound, "/admin/settings/site?saved=1")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- Upload settings ----------------
|
||||
|
||||
// fileTypeView augments an UploadFileType with a pre-rendered max-size MB
|
||||
// string for the template (avoids needing a template FuncMap for division).
|
||||
type fileTypeView struct {
|
||||
models.UploadFileType
|
||||
MaxSizeMB string
|
||||
}
|
||||
|
||||
// UploadSettingsPage renders the upload policy + file-type management page.
|
||||
func UploadSettingsPage(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
var u models.UploadConfig
|
||||
if err := db.First(&u, 1).Error; err != nil {
|
||||
u = models.UploadConfig{ID: 1, Enabled: true, DefaultMaxSize: models.DefaultUploadMaxSize, StorageDir: "attachments"}
|
||||
}
|
||||
var types []models.UploadFileType
|
||||
db.Order("category asc, sort asc, id asc").Find(&types)
|
||||
|
||||
views := make([]fileTypeView, 0, len(types))
|
||||
for _, t := range types {
|
||||
views = append(views, fileTypeView{UploadFileType: t, MaxSizeMB: bytesToMB(t.MaxSize)})
|
||||
}
|
||||
|
||||
data := DefaultData(c)
|
||||
data["Title"] = tr["settings_upload_title"]
|
||||
data["Upload"] = u
|
||||
data["FileTypes"] = views
|
||||
data["DefaultMaxSizeMB"] = bytesToMB(u.DefaultMaxSize)
|
||||
if msg := c.Query("saved"); msg == "1" {
|
||||
data["Success"] = tr["settings_saved"]
|
||||
}
|
||||
c.HTML(http.StatusOK, "settings_upload", data)
|
||||
}
|
||||
}
|
||||
|
||||
// UploadSettingsSave dispatches upload-config and file-type actions.
|
||||
func UploadSettingsSave(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
switch c.PostForm("action") {
|
||||
case "save_config":
|
||||
saveUploadConfig(db, c)
|
||||
case "add_type":
|
||||
addUploadFileType(db, c)
|
||||
case "toggle_type":
|
||||
toggleUploadFileType(db, c)
|
||||
case "size_type":
|
||||
sizeUploadFileType(db, c)
|
||||
case "delete_type":
|
||||
deleteUploadFileType(db, c)
|
||||
}
|
||||
models.RefreshConfigCache(db)
|
||||
c.Redirect(http.StatusFound, "/admin/settings/upload?saved=1")
|
||||
}
|
||||
}
|
||||
|
||||
func saveUploadConfig(db *gorm.DB, c *gin.Context) {
|
||||
var u models.UploadConfig
|
||||
if err := db.First(&u, 1).Error; err != nil {
|
||||
u = models.UploadConfig{ID: 1}
|
||||
}
|
||||
u.Enabled = c.PostForm("enabled") == "1"
|
||||
u.DefaultMaxSize = mbToBytes(c.PostForm("default_max_size"))
|
||||
if u.DefaultMaxSize <= 0 {
|
||||
u.DefaultMaxSize = models.DefaultUploadMaxSize
|
||||
}
|
||||
if dir := strings.TrimSpace(c.PostForm("storage_dir")); dir != "" {
|
||||
u.StorageDir = dir
|
||||
}
|
||||
u.UpdatedBy = userIDFromSession(c)
|
||||
db.Save(&u)
|
||||
}
|
||||
|
||||
func addUploadFileType(db *gorm.DB, c *gin.Context) {
|
||||
ext := strings.ToLower(strings.TrimSpace(c.PostForm("extension")))
|
||||
if ext == "" {
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(ext, ".") {
|
||||
ext = "." + ext
|
||||
}
|
||||
t := models.UploadFileType{
|
||||
Extension: ext,
|
||||
MimeType: strings.TrimSpace(c.PostForm("mime_type")),
|
||||
Category: strings.TrimSpace(c.PostForm("category")),
|
||||
MaxSize: mbToBytes(c.PostForm("max_size")),
|
||||
Enabled: c.PostForm("enabled") != "0",
|
||||
Sort: 50,
|
||||
}
|
||||
if t.Category == "" {
|
||||
t.Category = models.CategoryOther
|
||||
}
|
||||
// Ignore duplicate-extension errors silently.
|
||||
db.Where("extension = ?", t.Extension).FirstOrCreate(&t)
|
||||
}
|
||||
|
||||
func toggleUploadFileType(db *gorm.DB, c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.PostForm("id"))
|
||||
var t models.UploadFileType
|
||||
if db.First(&t, id).Error != nil {
|
||||
return
|
||||
}
|
||||
t.Enabled = !t.Enabled
|
||||
db.Save(&t)
|
||||
}
|
||||
|
||||
func sizeUploadFileType(db *gorm.DB, c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.PostForm("id"))
|
||||
var t models.UploadFileType
|
||||
if db.First(&t, id).Error != nil {
|
||||
return
|
||||
}
|
||||
t.MaxSize = mbToBytes(c.PostForm("max_size"))
|
||||
db.Save(&t)
|
||||
}
|
||||
|
||||
func deleteUploadFileType(db *gorm.DB, c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.PostForm("id"))
|
||||
db.Delete(&models.UploadFileType{}, id)
|
||||
}
|
||||
|
||||
// ---------------- Download settings ----------------
|
||||
|
||||
// DownloadSettingsPage renders the download base-URL management page.
|
||||
func DownloadSettingsPage(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
var urls []models.DownloadBaseURL
|
||||
db.Order("is_default desc, priority asc, id asc").Find(&urls)
|
||||
|
||||
data := DefaultData(c)
|
||||
data["Title"] = tr["settings_download_title"]
|
||||
data["BaseURLs"] = urls
|
||||
if msg := c.Query("saved"); msg == "1" {
|
||||
data["Success"] = tr["settings_saved"]
|
||||
}
|
||||
c.HTML(http.StatusOK, "settings_download", data)
|
||||
}
|
||||
}
|
||||
|
||||
// DownloadSettingsSave dispatches download base-URL actions.
|
||||
func DownloadSettingsSave(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
switch c.PostForm("action") {
|
||||
case "add":
|
||||
addDownloadBaseURL(db, c)
|
||||
case "toggle":
|
||||
toggleDownloadBaseURL(db, c)
|
||||
case "default":
|
||||
defaultDownloadBaseURL(db, c)
|
||||
case "delete":
|
||||
deleteDownloadBaseURL(db, c)
|
||||
}
|
||||
models.RefreshConfigCache(db)
|
||||
c.Redirect(http.StatusFound, "/admin/settings/download?saved=1")
|
||||
}
|
||||
}
|
||||
|
||||
func addDownloadBaseURL(db *gorm.DB, c *gin.Context) {
|
||||
name := strings.TrimSpace(c.PostForm("name"))
|
||||
base := strings.TrimSpace(c.PostForm("base_url"))
|
||||
if base == "" {
|
||||
return
|
||||
}
|
||||
prio, _ := strconv.Atoi(c.PostForm("priority"))
|
||||
b := models.DownloadBaseURL{
|
||||
Name: name,
|
||||
BaseURL: base,
|
||||
Priority: prio,
|
||||
Enabled: c.PostForm("enabled") != "0",
|
||||
}
|
||||
db.Create(&b)
|
||||
}
|
||||
|
||||
func toggleDownloadBaseURL(db *gorm.DB, c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.PostForm("id"))
|
||||
var b models.DownloadBaseURL
|
||||
if db.First(&b, id).Error != nil {
|
||||
return
|
||||
}
|
||||
b.Enabled = !b.Enabled
|
||||
db.Save(&b)
|
||||
}
|
||||
|
||||
func defaultDownloadBaseURL(db *gorm.DB, c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.PostForm("id"))
|
||||
// Only one default at a time.
|
||||
db.Model(&models.DownloadBaseURL{}).Where("1=1").Update("is_default", false)
|
||||
db.Model(&models.DownloadBaseURL{}).Where("id = ?", id).Update("is_default", true)
|
||||
}
|
||||
|
||||
func deleteDownloadBaseURL(db *gorm.DB, c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.PostForm("id"))
|
||||
db.Delete(&models.DownloadBaseURL{}, id)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"go_blog/models"
|
||||
)
|
||||
|
||||
// ErrUploadsDisabled is returned when the global upload switch is off.
|
||||
var ErrUploadsDisabled = errors.New("uploads are disabled")
|
||||
|
||||
// FileValidationError describes why an uploaded file was rejected.
|
||||
type FileValidationError struct {
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (e *FileValidationError) Error() string { return e.Reason }
|
||||
|
||||
// FileCheck is the outcome of validating an uploaded file header.
|
||||
type FileCheck struct {
|
||||
OK bool
|
||||
Type *models.UploadFileType // matched type, nil if not found
|
||||
MaxSize int64 // effective byte limit applied
|
||||
}
|
||||
|
||||
// ValidateUpload checks a file header against the cached platform upload
|
||||
// policy: master switch, extension whitelist, and per-type size limit. The
|
||||
// reported MaxSize is the effective limit (per-type override, else default).
|
||||
func ValidateUpload(header *multipart.FileHeader) FileCheck {
|
||||
cfg := models.GetUploadConfig()
|
||||
|
||||
if !cfg.Enabled {
|
||||
return FileCheck{OK: false}
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||
def := cfg.DefaultMaxSize
|
||||
for i := range models.GetUploadFileTypes() {
|
||||
t := &models.GetUploadFileTypes()[i]
|
||||
if !t.Enabled {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(t.Extension, ext) {
|
||||
max := t.EffectiveMaxSize(def)
|
||||
if header.Size > max {
|
||||
return FileCheck{OK: false, Type: t, MaxSize: max}
|
||||
}
|
||||
return FileCheck{OK: true, Type: t, MaxSize: max}
|
||||
}
|
||||
}
|
||||
|
||||
// Extension not in the whitelist.
|
||||
return FileCheck{OK: false, MaxSize: def}
|
||||
}
|
||||
|
||||
// formatSize renders a byte count as a human-readable string.
|
||||
func formatSize(b int64) string {
|
||||
const unit = 1024
|
||||
if b < unit {
|
||||
return fmt.Sprintf("%d B", b)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for n := b / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %ciB", float64(b)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
Reference in New Issue
Block a user