Features: - Custom navigation links management in admin settings - Multi-language support for navigation links (Chinese/English) - Control link behavior (open in new window) - Sort order and enable/disable toggle for nav links - User registration system with validation - Admin can enable/disable user registration - Registration form with username, display name, email, password - Link to registration from login page Navigation Links: - Admin interface to add/edit/delete custom nav links - Support for both internal and external URLs - Display links in header navigation bar - Respects language preference User Registration: - Username validation (3-32 characters, alphanumeric + underscore/dash) - Password validation (minimum 6 characters) - Password confirmation matching - Optional display name and email fields - Can be toggled on/off by admin in site settings Templates: - Added navigation links settings page - Added user registration page - Updated login page with registration link - Updated base layout to render custom nav links Documentation: - NAV_LINKS_FEATURE.md - Navigation links feature documentation - REGISTRATION_FEATURE.md - User registration documentation - IMPLEMENTATION_SUMMARY.md - Overall implementation summary Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
532 lines
15 KiB
Go
532 lines
15 KiB
Go
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. It
|
|
// defends against int/uint/int64/float64 storage in the session.
|
|
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
|
|
}
|
|
|
|
// ---------------- 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
|
|
// Pre-compute derived values so the template never invokes methods on
|
|
// an interface{}-wrapped struct (which Go templates cannot resolve).
|
|
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 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.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"))
|
|
s.AllowRegistration = c.PostForm("allow_registration") == "1"
|
|
s.UpdatedBy = userIDFromSession(c)
|
|
|
|
// Favicon upload (optional). A favicon_url form field takes precedence over an
|
|
// uploaded file, so admins can set either a local file or an external link.
|
|
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)
|
|
// Remove the previous local favicon (skip external URLs).
|
|
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
|
|
}
|
|
s.Favicon = savedName
|
|
}
|
|
|
|
// Remove favicon entirely if requested.
|
|
if c.PostForm("favicon_clear") == "1" {
|
|
if s.Favicon != "" && !s.FaviconIsURL() {
|
|
os.Remove(filepath.Join(storagePath, "logos", s.Favicon))
|
|
}
|
|
s.Favicon = ""
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// ---------------- Comment settings ----------------
|
|
|
|
// CommentSettingsPage renders the comment policy form.
|
|
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)
|
|
}
|
|
}
|
|
|
|
// CommentSettingsSave persists the comment policy toggles and refreshes the
|
|
// in-memory cache so subsequent requests see the change.
|
|
func CommentSettingsSave(db *gorm.DB) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
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.UpdatedBy = userIDFromSession(c)
|
|
db.Save(&cc)
|
|
models.RefreshConfigCache(db)
|
|
c.Redirect(http.StatusFound, "/admin/settings/comments?saved=1")
|
|
}
|
|
}
|
|
|
|
// ---------------- Navigation Links settings ----------------
|
|
|
|
// NavLinksSettingsPage renders the navigation links management page.
|
|
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)
|
|
}
|
|
}
|
|
|
|
// NavLinksSettingsSave dispatches navigation link actions.
|
|
func NavLinksSettingsSave(db *gorm.DB) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
switch c.PostForm("action") {
|
|
case "add":
|
|
addNavLink(db, c)
|
|
case "toggle":
|
|
toggleNavLink(db, c)
|
|
case "edit":
|
|
editNavLink(db, c)
|
|
case "delete":
|
|
deleteNavLink(db, c)
|
|
}
|
|
models.RefreshConfigCache(db)
|
|
c.Redirect(http.StatusFound, "/admin/settings/navlinks?saved=1")
|
|
}
|
|
}
|
|
|
|
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"))
|
|
|
|
if url == "" || (titleZh == "" && titleEn == "") {
|
|
return
|
|
}
|
|
|
|
sort, _ := strconv.Atoi(c.PostForm("sort"))
|
|
|
|
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),
|
|
}
|
|
db.Create(&link)
|
|
}
|
|
|
|
func toggleNavLink(db *gorm.DB, c *gin.Context) {
|
|
id, _ := strconv.Atoi(c.PostForm("id"))
|
|
var link models.NavLink
|
|
if db.First(&link, id).Error != nil {
|
|
return
|
|
}
|
|
link.Enabled = !link.Enabled
|
|
link.UpdatedBy = userIDFromSession(c)
|
|
db.Save(&link)
|
|
}
|
|
|
|
func editNavLink(db *gorm.DB, c *gin.Context) {
|
|
id, _ := strconv.Atoi(c.PostForm("id"))
|
|
var link models.NavLink
|
|
if db.First(&link, id).Error != nil {
|
|
return
|
|
}
|
|
|
|
titleZh := strings.TrimSpace(c.PostForm("title_zh"))
|
|
titleEn := strings.TrimSpace(c.PostForm("title_en"))
|
|
url := strings.TrimSpace(c.PostForm("url"))
|
|
|
|
if url == "" || (titleZh == "" && titleEn == "") {
|
|
return
|
|
}
|
|
|
|
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)
|
|
db.Save(&link)
|
|
}
|
|
|
|
func deleteNavLink(db *gorm.DB, c *gin.Context) {
|
|
id, _ := strconv.Atoi(c.PostForm("id"))
|
|
db.Delete(&models.NavLink{}, id)
|
|
}
|
|
|