diff --git a/handlers/helpers.go b/handlers/helpers.go index fc01b1c..6ecb537 100644 --- a/handlers/helpers.go +++ b/handlers/helpers.go @@ -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, } } diff --git a/handlers/profile.go b/handlers/profile.go index 0d28654..02c796f 100644 --- a/handlers/profile.go +++ b/handlers/profile.go @@ -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 { diff --git a/handlers/settings.go b/handlers/settings.go new file mode 100644 index 0000000..1ce7ece --- /dev/null +++ b/handlers/settings.go @@ -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) +} diff --git a/handlers/upload_validator.go b/handlers/upload_validator.go new file mode 100644 index 0000000..825188c --- /dev/null +++ b/handlers/upload_validator.go @@ -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]) +} diff --git a/i18n/i18n.go b/i18n/i18n.go index 93a0bd0..fa52f12 100644 --- a/i18n/i18n.go +++ b/i18n/i18n.go @@ -85,6 +85,9 @@ var translations = map[Lang]map[string]string{ "profile_save": "Save Changes", "profile_saved": "Profile updated.", "profile_wrong_password": "Current password is incorrect.", + "profile_upload_invalid": "File type not allowed.", + "profile_upload_disabled": "Uploads are currently disabled.", + "profile_upload_too_large": "File is too large. Limit: %s", "gender_male": "Male", "gender_female": "Female", "gender_other": "Other", @@ -133,6 +136,53 @@ var translations = map[Lang]map[string]string{ "article_col_status": "Status", "article_col_published": "Published", "article_col_actions": "Actions", + + // Settings (platform configuration) + "settings_nav": "Platform Settings", + "settings_saved": "Settings saved.", + "settings_site_title": "Site Settings", + "settings_site_desc": "Logo, top-left title, header banner, and footer text.", + "settings_logo": "Logo", + "settings_logo_url": "Logo URL (external link)", + "settings_logo_upload": "Or upload a logo image", + "settings_logo_clear": "Remove current logo", + "settings_logo_text_zh": "Site Title (Chinese)", + "settings_logo_text_en": "Site Title (English)", + "settings_header_text_zh": "Header Banner Text (Chinese)", + "settings_header_text_en": "Header Banner Text (English)", + "settings_footer_text_zh": "Footer Text (Chinese)", + "settings_footer_text_en": "Footer Text (English)", + "settings_leave_blank": "Leave blank to use the built-in default.", + "settings_save": "Save", + "settings_upload_title": "Upload Settings", + "settings_upload_desc": "Attachment upload policy and permitted file types.", + "settings_uploads_enabled":"Enable attachments", + "settings_default_size": "Default max size (MB)", + "settings_storage_dir": "Storage sub-directory", + "settings_file_types": "Permitted File Types", + "settings_add_type": "Add File Type", + "settings_extension": "Extension", + "settings_mime": "MIME Type", + "settings_category": "Category", + "settings_max_size": "Max Size (MB)", + "settings_enabled": "Enabled", + "settings_actions": "Actions", + "settings_toggle": "Toggle", + "settings_delete": "Delete", + "settings_update": "Update", + "settings_download_title": "Download Settings", + "settings_download_desc": "Base URLs used to build attachment download links.", + "settings_add_url": "Add Base URL", + "settings_url_name": "Name", + "settings_base_url": "Base URL", + "settings_priority": "Priority", + "settings_is_default": "Default", + "settings_set_default": "Set Default", + "cat_image": "Image", + "cat_document": "Document", + "cat_archive": "Archive", + "cat_video": "Video", + "cat_other": "Other", }, ZH: { // 导航 @@ -203,6 +253,9 @@ var translations = map[Lang]map[string]string{ "profile_save": "保存修改", "profile_saved": "个人信息已更新。", "profile_wrong_password": "当前密码错误。", + "profile_upload_invalid": "不允许的文件类型。", + "profile_upload_disabled": "上传功能已关闭。", + "profile_upload_too_large": "文件过大。限制:%s", "gender_male": "男", "gender_female": "女", "gender_other": "其他", @@ -251,6 +304,53 @@ var translations = map[Lang]map[string]string{ "article_col_status": "状态", "article_col_published": "发布时间", "article_col_actions": "操作", + + // 平台设置 + "settings_nav": "平台设置", + "settings_saved": "设置已保存。", + "settings_site_title": "站点设置", + "settings_site_desc": "Logo、左上角标题、顶部横幅文本和页脚文本。", + "settings_logo": "Logo", + "settings_logo_url": "Logo 链接(外链地址)", + "settings_logo_upload": "或上传 Logo 图片", + "settings_logo_clear": "移除当前 Logo", + "settings_logo_text_zh": "站点标题(中文)", + "settings_logo_text_en": "站点标题(英文)", + "settings_header_text_zh": "顶部横幅文本(中文)", + "settings_header_text_en": "顶部横幅文本(英文)", + "settings_footer_text_zh": "页脚文本(中文)", + "settings_footer_text_en": "页脚文本(英文)", + "settings_leave_blank": "留空则使用内置默认值。", + "settings_save": "保存", + "settings_upload_title": "上传设置", + "settings_upload_desc": "附件上传策略与允许的文件类型。", + "settings_uploads_enabled":"启用附件上传", + "settings_default_size": "默认最大大小(MB)", + "settings_storage_dir": "存储子目录", + "settings_file_types": "允许的文件类型", + "settings_add_type": "添加文件类型", + "settings_extension": "扩展名", + "settings_mime": "MIME 类型", + "settings_category": "分组", + "settings_max_size": "最大大小(MB)", + "settings_enabled": "启用", + "settings_actions": "操作", + "settings_toggle": "切换", + "settings_delete": "删除", + "settings_update": "更新", + "settings_download_title": "下载设置", + "settings_download_desc": "用于拼接附件下载链接的基础地址。", + "settings_add_url": "添加基础地址", + "settings_url_name": "名称", + "settings_base_url": "基础地址", + "settings_priority": "优先级", + "settings_is_default": "默认", + "settings_set_default": "设为默认", + "cat_image": "图片", + "cat_document": "文档", + "cat_archive": "压缩包", + "cat_video": "视频", + "cat_other": "其他", }, } diff --git a/main.go b/main.go index 9e0e3e2..54af794 100644 --- a/main.go +++ b/main.go @@ -21,6 +21,9 @@ func main() { // 2. Initialize the database (auto-migrates, seeds admin). db := models.InitDB(cfg) + // 2b. Warm the platform configuration cache from the database. + models.LoadConfigCache(db) + // 3. Create session store (cookie-based). store := cookie.NewStore([]byte(cfg.Secret)) store.Options(sessions.Options{ @@ -65,6 +68,18 @@ func main() { admin.POST("/articles/:id/delete", handlers.ArticleDelete(db)) } + // Protected admin settings routes (platform configuration). + settings := router.Group("/admin/settings") + settings.Use(middleware.AuthRequired()) + { + settings.GET("/site", handlers.SiteSettingsPage(db)) + settings.POST("/site", handlers.SiteSettingsSave(db, cfg.Path)) + settings.GET("/upload", handlers.UploadSettingsPage(db)) + settings.POST("/upload", handlers.UploadSettingsSave(db)) + settings.GET("/download", handlers.DownloadSettingsPage(db)) + settings.POST("/download", handlers.DownloadSettingsSave(db)) + } + // Protected profile routes. profile := router.Group("/profile") profile.Use(middleware.AuthRequired()) diff --git a/middleware/auth.go b/middleware/auth.go index 4e88e24..f3a92ed 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -95,6 +95,16 @@ func SetUserContext(db *gorm.DB) gin.HandlerFunc { c.Set("avatar", avatar) c.Set("display_name", displayName) c.Set("role", role) + + // --- Site platform configuration (from DB cache) --- + site := models.GetSiteSetting() + c.Set("site_setting", site) + c.Set("site_logo", site.Logo) + c.Set("site_logo_is_url", site.LogoIsURL()) + c.Set("site_logo_text", site.LogoText(string(lang))) + c.Set("site_header_text", site.HeaderText(string(lang))) + c.Set("site_footer_text", site.FooterText(string(lang))) + c.Next() } } diff --git a/models/config_cache.go b/models/config_cache.go new file mode 100644 index 0000000..e33b3bc --- /dev/null +++ b/models/config_cache.go @@ -0,0 +1,105 @@ +package models + +import ( + "sync" + + "gorm.io/gorm" +) + +// configCache holds process-level caches of platform configuration so that +// per-request rendering and upload validation do not hit the database. The +// cache is populated once at startup and refreshed whenever an admin saves a +// settings page (see RefreshConfigCache / the admin handlers). +var configCache = struct { + mu sync.RWMutex + site *SiteSetting + upload *UploadConfig + types []UploadFileType + baseURLs []DownloadBaseURL +}{ + site: &SiteSetting{}, + upload: &UploadConfig{Enabled: true, DefaultMaxSize: DefaultUploadMaxSize, StorageDir: "attachments"}, +} + +// LoadConfigCache reads all platform configuration from the database into the +// process cache. Called once at startup after InitDB. +func LoadConfigCache(db *gorm.DB) { + configCache.mu.Lock() + defer configCache.mu.Unlock() + + var s SiteSetting + if err := db.First(&s, 1).Error; err == nil { + configCache.site = &s + } else { + configCache.site = &SiteSetting{ID: 1} + } + + var u UploadConfig + if err := db.First(&u, 1).Error; err == nil { + configCache.upload = &u + } else { + configCache.upload = &UploadConfig{ID: 1, Enabled: true, DefaultMaxSize: DefaultUploadMaxSize, StorageDir: "attachments"} + } + + var t []UploadFileType + if err := db.Order("sort asc, id asc").Find(&t).Error; err == nil { + configCache.types = t + } + + var b []DownloadBaseURL + if err := db.Order("is_default desc, priority asc, id asc").Find(&b).Error; err == nil { + configCache.baseURLs = b + } +} + +// RefreshConfigCache reloads all cached platform configuration. Admin handlers +// call this after writing changes so the next request sees them. +func RefreshConfigCache(db *gorm.DB) { + LoadConfigCache(db) +} + +// GetSiteSetting returns a pointer to the cached site settings (read-only copy +// semantics: callers must not mutate). +func GetSiteSetting() *SiteSetting { + configCache.mu.RLock() + defer configCache.mu.RUnlock() + return configCache.site +} + +// GetUploadConfig returns the cached upload policy. +func GetUploadConfig() *UploadConfig { + configCache.mu.RLock() + defer configCache.mu.RUnlock() + return configCache.upload +} + +// GetUploadFileTypes returns the cached list of permitted file types. +func GetUploadFileTypes() []UploadFileType { + configCache.mu.RLock() + defer configCache.mu.RUnlock() + return configCache.types +} + +// GetDownloadBaseURLs returns the cached list of download base URLs. +func GetDownloadBaseURLs() []DownloadBaseURL { + configCache.mu.RLock() + defer configCache.mu.RUnlock() + return configCache.baseURLs +} + +// DefaultDownloadBaseURL returns the base URL used to build attachment download +// links: the enabled row marked IsDefault, else the highest-priority enabled +// row. Returns an empty string if none is configured. +func DefaultDownloadBaseURL() string { + configCache.mu.RLock() + defer configCache.mu.RUnlock() + + // Rows are ordered is_default desc, priority asc, so the first enabled row + // is the right pick. + for _, b := range configCache.baseURLs { + if b.Enabled { + return b.BaseURL + } + } + return "" +} diff --git a/models/db.go b/models/db.go index a733b87..25cdb30 100644 --- a/models/db.go +++ b/models/db.go @@ -45,10 +45,15 @@ func InitDB(cfg *config.Config) *gorm.DB { } // Auto-migrate tables (idempotent). - if err := db.AutoMigrate(&User{}, &Article{}); err != nil { + if err := db.AutoMigrate(&User{}, &Article{}, &SiteSetting{}, &UploadConfig{}, &UploadFileType{}, &DownloadBaseURL{}); err != nil { log.Fatalf("Failed to auto-migrate database: %v", err) } + // Seed site platform configuration on first run. + seedSiteSettings(db) + seedUploadConfig(db) + seedUploadFileTypes(db) + // First-run seed: create admin user if no users exist. var count int64 db.Model(&User{}).Count(&count) diff --git a/models/seed.go b/models/seed.go new file mode 100644 index 0000000..39d54e2 --- /dev/null +++ b/models/seed.go @@ -0,0 +1,79 @@ +package models + +import ( + "log" + + "gorm.io/gorm" +) + +// seedSiteSettings inserts the singleton site_settings row (id=1) if absent. +// Text fields are left empty so templates fall back to i18n defaults until an +// admin configures them. +func seedSiteSettings(db *gorm.DB) { + var count int64 + db.Model(&SiteSetting{}).Count(&count) + if count > 0 { + return + } + s := &SiteSetting{ID: 1} + if err := db.Create(s).Error; err != nil { + log.Printf("Warning: failed to seed site_settings: %v", err) + } +} + +// seedUploadConfig inserts the singleton upload_configs row (id=1) if absent. +func seedUploadConfig(db *gorm.DB) { + var count int64 + db.Model(&UploadConfig{}).Count(&count) + if count > 0 { + return + } + c := &UploadConfig{ + ID: 1, + Enabled: true, + DefaultMaxSize: DefaultUploadMaxSize, + StorageDir: "attachments", + } + if err := db.Create(c).Error; err != nil { + log.Printf("Warning: failed to seed upload_configs: %v", err) + } +} + +// defaultUploadFileTypes is the set of commonly permitted attachment types +// seeded on first run. +var defaultUploadFileTypes = []UploadFileType{ + // Images + {Extension: ".jpg", MimeType: "image/jpeg", Category: CategoryImage, Enabled: true, Sort: 1}, + {Extension: ".jpeg", MimeType: "image/jpeg", Category: CategoryImage, Enabled: true, Sort: 2}, + {Extension: ".png", MimeType: "image/png", Category: CategoryImage, Enabled: true, Sort: 3}, + {Extension: ".gif", MimeType: "image/gif", Category: CategoryImage, Enabled: true, Sort: 4}, + {Extension: ".webp", MimeType: "image/webp", Category: CategoryImage, Enabled: true, Sort: 5}, + // Documents + {Extension: ".pdf", MimeType: "application/pdf", Category: CategoryDocument, Enabled: true, Sort: 10}, + {Extension: ".doc", MimeType: "application/msword", Category: CategoryDocument, Enabled: true, Sort: 11}, + {Extension: ".docx", MimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", Category: CategoryDocument, Enabled: true, Sort: 12}, + {Extension: ".xls", MimeType: "application/vnd.ms-excel", Category: CategoryDocument, Enabled: true, Sort: 13}, + {Extension: ".xlsx", MimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", Category: CategoryDocument, Enabled: true, Sort: 14}, + {Extension: ".ppt", MimeType: "application/vnd.ms-powerpoint", Category: CategoryDocument, Enabled: true, Sort: 15}, + {Extension: ".pptx", MimeType: "application/vnd.openxmlformats-officedocument.presentationml.presentation", Category: CategoryDocument, Enabled: true, Sort: 16}, + {Extension: ".txt", MimeType: "text/plain", Category: CategoryDocument, Enabled: true, Sort: 17}, + // Archives + {Extension: ".zip", MimeType: "application/zip", Category: CategoryArchive, Enabled: true, Sort: 20}, + {Extension: ".rar", MimeType: "application/vnd.rar", Category: CategoryArchive, Enabled: true, Sort: 21}, + {Extension: ".7z", MimeType: "application/x-7z-compressed", Category: CategoryArchive, Enabled: true, Sort: 22}, + // Video + {Extension: ".mp4", MimeType: "video/mp4", Category: CategoryVideo, Enabled: true, Sort: 30}, + {Extension: ".avi", MimeType: "video/x-msvideo", Category: CategoryVideo, Enabled: true, Sort: 31}, +} + +// seedUploadFileTypes seeds the permitted file-type rows if the table is empty. +func seedUploadFileTypes(db *gorm.DB) { + var count int64 + db.Model(&UploadFileType{}).Count(&count) + if count > 0 { + return + } + if err := db.Create(&defaultUploadFileTypes).Error; err != nil { + log.Printf("Warning: failed to seed upload_file_types: %v", err) + } +} diff --git a/models/site_setting.go b/models/site_setting.go new file mode 100644 index 0000000..58c624c --- /dev/null +++ b/models/site_setting.go @@ -0,0 +1,78 @@ +package models + +import "time" + +// SiteSetting holds the singleton (id=1) global display configuration: +// logo, top-left title, header banner text, and footer text, each with +// zh/en variants that fall back to i18n defaults when empty. +type SiteSetting struct { + ID uint `gorm:"primarykey" json:"id"` + Logo string `gorm:"size:512" json:"logo"` // local filename (served under /uploads/logos) OR a full URL + LogoTextZh string `gorm:"size:255" json:"logo_text_zh"` // top-left title (zh) + LogoTextEn string `gorm:"size:255" json:"logo_text_en"` // top-left title (en) + HeaderTextZh string `gorm:"size:512" json:"header_text_zh"` // header banner text (zh) + HeaderTextEn string `gorm:"size:512" json:"header_text_en"` // header banner text (en) + FooterTextZh string `gorm:"size:512" json:"footer_text_zh"` // footer text (zh) + FooterTextEn string `gorm:"size:512" json:"footer_text_en"` // footer text (en) + UpdatedBy uint `gorm:"index" json:"updated_by"` + UpdatedAt time.Time `json:"updated_at"` +} + +// TableName overrides the default GORM table name. +func (SiteSetting) TableName() string { + return "site_settings" +} + +// LogoIsURL reports whether the logo value is an external URL rather than a +// local filename. +func (s *SiteSetting) LogoIsURL() bool { + if s == nil || s.Logo == "" { + return false + } + return len(s.Logo) >= 4 && (s.Logo[:4] == "http") +} + +// LogoText returns the title for the given language code, falling back to the +// other language when the requested one is empty. +func (s *SiteSetting) LogoText(lang string) string { + if lang == "zh" { + if s.LogoTextZh != "" { + return s.LogoTextZh + } + return s.LogoTextEn + } + if s.LogoTextEn != "" { + return s.LogoTextEn + } + return s.LogoTextZh +} + +// HeaderText returns the header banner text for the given language code, +// falling back to the other language when the requested one is empty. +func (s *SiteSetting) HeaderText(lang string) string { + if lang == "zh" { + if s.HeaderTextZh != "" { + return s.HeaderTextZh + } + return s.HeaderTextEn + } + if s.HeaderTextEn != "" { + return s.HeaderTextEn + } + return s.HeaderTextZh +} + +// FooterText returns the footer text for the given language code, falling +// back to the other language when the requested one is empty. +func (s *SiteSetting) FooterText(lang string) string { + if lang == "zh" { + if s.FooterTextZh != "" { + return s.FooterTextZh + } + return s.FooterTextEn + } + if s.FooterTextEn != "" { + return s.FooterTextEn + } + return s.FooterTextZh +} diff --git a/models/upload_config.go b/models/upload_config.go new file mode 100644 index 0000000..4af8107 --- /dev/null +++ b/models/upload_config.go @@ -0,0 +1,74 @@ +package models + +import "time" + +// UploadCategory groups file types for the admin UI. +const ( + CategoryImage = "image" + CategoryDocument = "document" + CategoryArchive = "archive" + CategoryVideo = "video" + CategoryOther = "other" +) + +// DefaultUploadMaxSize is the default per-file size limit (10 MiB), in bytes. +const DefaultUploadMaxSize int64 = 10 * 1024 * 1024 + +// UploadConfig holds the singleton (id=1) global attachment upload policy. +type UploadConfig struct { + ID uint `gorm:"primarykey" json:"id"` + Enabled bool `gorm:"default:true" json:"enabled"` // master switch for attachment uploads + DefaultMaxSize int64 `gorm:"default:10485760" json:"default_max_size"` // bytes; overridden per type by UploadFileType.MaxSize + StorageDir string `gorm:"size:255;default:attachments" json:"storage_dir"` // sub-dir under cfg.Path + UpdatedBy uint `gorm:"index" json:"updated_by"` + UpdatedAt time.Time `json:"updated_at"` +} + +// TableName overrides the default GORM table name. +func (UploadConfig) TableName() string { + return "upload_configs" +} + +// UploadFileType describes one permitted attachment extension. Multiple rows. +type UploadFileType struct { + ID uint `gorm:"primarykey" json:"id"` + Extension string `gorm:"size:32;uniqueIndex" json:"extension"` // with leading dot, e.g. ".pdf" + MimeType string `gorm:"size:128" json:"mime_type"` // associated MIME for validation + Category string `gorm:"size:32;index" json:"category"` // image/document/archive/video/other + MaxSize int64 `gorm:"default:0" json:"max_size"` // bytes; 0 means use UploadConfig.DefaultMaxSize + Enabled bool `gorm:"default:true" json:"enabled"` + Sort int `gorm:"default:0" json:"sort"` +} + +// TableName overrides the default GORM table name. +func (UploadFileType) TableName() string { + return "upload_file_types" +} + +// EffectiveMaxSize returns the per-file size limit for this type, falling back +// to the provided default when MaxSize is 0. +func (t *UploadFileType) EffectiveMaxSize(def int64) int64 { + if t.MaxSize > 0 { + return t.MaxSize + } + return def +} + +// DownloadBaseURL is one source base URL used to build attachment download +// links. Multiple rows; the row marked IsDefault (or the highest-priority +// enabled one) is used for generated links. +type DownloadBaseURL struct { + ID uint `gorm:"primarykey" json:"id"` + Name string `gorm:"size:64" json:"name"` // label, e.g. "主站" / "CDN" + BaseURL string `gorm:"size:512" json:"base_url"` // e.g. https://cdn.example.com/uploads + Priority int `gorm:"default:0" json:"priority"` // lower = higher priority + IsDefault bool `gorm:"default:false" json:"is_default"` + Enabled bool `gorm:"default:true" json:"enabled"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// TableName overrides the default GORM table name. +func (DownloadBaseURL) TableName() string { + return "download_baseurls" +} diff --git a/templates/admin/dashboard.html b/templates/admin/dashboard.html index 28de5bf..1ac2244 100644 --- a/templates/admin/dashboard.html +++ b/templates/admin/dashboard.html @@ -50,6 +50,10 @@ class="inline-flex items-center gap-2 bg-gray-200 text-gray-700 px-6 py-3 rounded-lg font-medium hover:bg-gray-300 transition-colors"> {{index .Tr "article_manage"}} + + {{index .Tr "settings_nav"}} + diff --git a/templates/admin/settings_download.html b/templates/admin/settings_download.html new file mode 100644 index 0000000..d4dadb1 --- /dev/null +++ b/templates/admin/settings_download.html @@ -0,0 +1,103 @@ +{{define "settings_download"}} +{{template "header" .}} +
+

{{index .Tr "settings_download_title"}}

+

{{index .Tr "settings_download_desc"}}

+ +
+ {{index .Tr "settings_site_title"}} + {{index .Tr "settings_upload_title"}} + {{index .Tr "settings_download_title"}} +
+ + {{if .Success}} +
{{.Success}}
+ {{end}} + +
+ + + + + + + + + + + + + {{range .BaseURLs}} + + + + + + + + + {{else}} + + + + {{end}} + +
{{index .Tr "settings_url_name"}}{{index .Tr "settings_base_url"}}{{index .Tr "settings_priority"}}{{index .Tr "settings_is_default"}}{{index .Tr "settings_enabled"}}{{index .Tr "settings_actions"}}
{{.Name}}{{.BaseURL}}{{.Priority}} + {{if .IsDefault}}{{end}} + + {{if .Enabled}} + {{else}}{{end}} + +
+ + + +
+
+ + + +
+
+ + + +
+
+
+ + +
+ +

{{index .Tr "settings_add_url"}}

+
+
+ + +
+
+ + +
+
+
+
+ + +
+ +
+ +
+
+{{template "footer" .}} +{{end}} diff --git a/templates/admin/settings_site.html b/templates/admin/settings_site.html new file mode 100644 index 0000000..e844745 --- /dev/null +++ b/templates/admin/settings_site.html @@ -0,0 +1,88 @@ +{{define "settings_site"}} +{{template "header" .}} +
+

{{index .Tr "settings_site_title"}}

+

{{index .Tr "settings_site_desc"}}

+ +
+ {{index .Tr "settings_site_title"}} + {{index .Tr "settings_upload_title"}} + {{index .Tr "settings_download_title"}} +
+ + {{if .Success}} +
{{.Success}}
+ {{end}} + +
+ +
+ + {{if .Site.Logo}} + {{if .Site.LogoIsURL}} + logo + {{else}} + logo + {{end}} + {{end}} + + + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +

{{index .Tr "settings_leave_blank"}}

+ + +
+
+{{template "footer" .}} +{{end}} diff --git a/templates/admin/settings_upload.html b/templates/admin/settings_upload.html new file mode 100644 index 0000000..4445990 --- /dev/null +++ b/templates/admin/settings_upload.html @@ -0,0 +1,139 @@ +{{define "settings_upload"}} +{{template "header" .}} +
+

{{index .Tr "settings_upload_title"}}

+

{{index .Tr "settings_upload_desc"}}

+ +
+ {{index .Tr "settings_site_title"}} + {{index .Tr "settings_upload_title"}} + {{index .Tr "settings_download_title"}} +
+ + {{if .Success}} +
{{.Success}}
+ {{end}} + + +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ +
+ + +
+
+

{{index .Tr "settings_file_types"}}

+
+ + + + + + + + + + + + + {{range .FileTypes}} + + + + + + + + + {{end}} + +
{{index .Tr "settings_extension"}}{{index .Tr "settings_mime"}}{{index .Tr "settings_category"}}{{index .Tr "settings_max_size"}}{{index .Tr "settings_enabled"}}{{index .Tr "settings_actions"}}
{{.Extension}}{{.MimeType}}{{index $.Tr (printf "cat_%s" .Category)}} +
+ + + + +
+
+ {{if .Enabled}} + {{else}}{{end}} + +
+ + + +
+
+ + + +
+
+
+ + +
+ +

{{index .Tr "settings_add_type"}}

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+{{template "footer" .}} +{{end}} diff --git a/templates/layouts/base.html b/templates/layouts/base.html index 7703705..e04808a 100644 --- a/templates/layouts/base.html +++ b/templates/layouts/base.html @@ -15,8 +15,15 @@