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:
2026-06-21 20:32:28 +08:00
co-authored by Claude Fable 5
parent b474ee009a
commit e3367e6e12
17 changed files with 1277 additions and 20 deletions
+39 -8
View File
@@ -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 {