feat: 门户网站初始提交

- Go + Gin + html/template 服务端渲染
- 主页:Google 风格搜索框 + 导航卡片
- 后台:卡片 CRUD、搜索引擎配置、主页背景/标题配置
- 图片上传:支持 jpg/jpeg/png/gif,自动压缩,缩略图参数 ?thumb=1
- 安全:登录日志、修改密码、IP 自动封禁、IP 白名单
- 访问统计:主页访问/卡片点击/搜索追踪、实时流量、IP 统计
- SQLite 存储(modernc.org/sqlite,纯 Go)
- 内存 Session + bcrypt 密码哈希
This commit is contained in:
2026-05-28 13:54:07 +08:00
commit c16a8dfbc4
42 changed files with 5295 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
package handlers
// admin.go currently has no additional handlers.
// LoginGet, LoginPost, Logout, and AdminIndex are defined in home.go
// for convenience since they share the session store dependency.
+46
View File
@@ -0,0 +1,46 @@
package handlers
import (
"net/http"
"strconv"
"simple_portal/models"
"github.com/gin-gonic/gin"
)
// AccessLogsGet 渲染访问日志页面,支持按IP和动作类型筛选。
func AccessLogsGet(c *gin.Context) {
username, _ := c.Get("username")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
page = 1
}
pageSize := 30
filterIP := c.Query("ip")
filterAction := c.DefaultQuery("action", "")
logs, total, err := models.GetAccessLogs(page, pageSize, filterIP, filterAction)
if err != nil {
logs = []models.AccessLog{}
total = 0
}
totalPages := (total + pageSize - 1) / pageSize
if totalPages < 1 {
totalPages = 1
}
c.HTML(http.StatusOK, "admin/access_logs.html", gin.H{
"Title": "访问日志",
"Username": username,
"Logs": logs,
"Page": page,
"TotalPages": totalPages,
"Total": total,
"FilterIP": filterIP,
"FilterAction": filterAction,
})
}
+197
View File
@@ -0,0 +1,197 @@
package handlers
import (
"net/http"
"strconv"
"simple_portal/models"
"github.com/gin-gonic/gin"
)
// CardsList renders the admin cards management page.
func CardsList(c *gin.Context) {
username, _ := c.Get("username")
cards, err := models.GetAllCards()
if err != nil {
c.HTML(http.StatusInternalServerError, "admin/cards.html", gin.H{
"Title": "卡片管理",
"Username": username,
"Error": "Failed to load cards",
"Cards": []models.Card{},
})
return
}
c.HTML(http.StatusOK, "admin/cards.html", gin.H{
"Title": "卡片管理",
"Username": username,
"Cards": cards,
})
}
// CardCreateGet renders the form for creating a new card.
func CardCreateGet(c *gin.Context) {
username, _ := c.Get("username")
c.HTML(http.StatusOK, "admin/card_form.html", gin.H{
"Title": "新建卡片",
"Username": username,
"Card": nil,
"IsEdit": false,
})
}
// CardCreatePost handles the form submission for creating a new card.
func CardCreatePost(c *gin.Context) {
card := &models.Card{
Icon: c.PostForm("icon"),
Title: c.PostForm("title"),
Subtitle: c.PostForm("subtitle"),
URL: c.PostForm("url"),
Enabled: c.PostForm("enabled") == "1",
}
if card.Title == "" || card.URL == "" {
username, _ := c.Get("username")
c.HTML(http.StatusOK, "admin/card_form.html", gin.H{
"Title": "新建卡片",
"Username": username,
"Card": card,
"IsEdit": false,
"Error": "标题和链接不能为空",
})
return
}
if err := models.CreateCard(card); err != nil {
username, _ := c.Get("username")
c.HTML(http.StatusOK, "admin/card_form.html", gin.H{
"Title": "新建卡片",
"Username": username,
"Card": card,
"IsEdit": false,
"Error": "创建失败: " + err.Error(),
})
return
}
c.Redirect(http.StatusFound, "/admin/cards")
}
// CardEditGet renders the form for editing an existing card.
func CardEditGet(c *gin.Context) {
username, _ := c.Get("username")
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.Redirect(http.StatusFound, "/admin/cards")
return
}
card, err := models.GetCardByID(id)
if err != nil || card == nil {
c.Redirect(http.StatusFound, "/admin/cards")
return
}
c.HTML(http.StatusOK, "admin/card_form.html", gin.H{
"Title": "编辑卡片",
"Username": username,
"Card": card,
"IsEdit": true,
})
}
// CardEditPost handles the form submission for updating an existing card.
func CardEditPost(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.Redirect(http.StatusFound, "/admin/cards")
return
}
card, err := models.GetCardByID(id)
if err != nil || card == nil {
c.Redirect(http.StatusFound, "/admin/cards")
return
}
card.Icon = c.PostForm("icon")
card.Title = c.PostForm("title")
card.Subtitle = c.PostForm("subtitle")
card.URL = c.PostForm("url")
card.Enabled = c.PostForm("enabled") == "1"
if card.Title == "" || card.URL == "" {
username, _ := c.Get("username")
c.HTML(http.StatusOK, "admin/card_form.html", gin.H{
"Title": "编辑卡片",
"Username": username,
"Card": card,
"IsEdit": true,
"Error": "标题和链接不能为空",
})
return
}
if err := models.UpdateCard(card); err != nil {
username, _ := c.Get("username")
c.HTML(http.StatusOK, "admin/card_form.html", gin.H{
"Title": "编辑卡片",
"Username": username,
"Card": card,
"IsEdit": true,
"Error": "更新失败: " + err.Error(),
})
return
}
c.Redirect(http.StatusFound, "/admin/cards")
}
// CardDelete handles deleting a card.
func CardDelete(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.Redirect(http.StatusFound, "/admin/cards")
return
}
_ = models.DeleteCard(id)
c.Redirect(http.StatusFound, "/admin/cards")
}
// CardToggle handles toggling a card's enabled status.
func CardToggle(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.Redirect(http.StatusFound, "/admin/cards")
return
}
_ = models.ToggleCard(id)
c.Redirect(http.StatusFound, "/admin/cards")
}
// CardMoveUp handles moving a card up in sort order.
func CardMoveUp(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.Redirect(http.StatusFound, "/admin/cards")
return
}
_ = models.MoveCardUp(id)
c.Redirect(http.StatusFound, "/admin/cards")
}
// CardMoveDown handles moving a card down in sort order.
func CardMoveDown(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.Redirect(http.StatusFound, "/admin/cards")
return
}
_ = models.MoveCardDown(id)
c.Redirect(http.StatusFound, "/admin/cards")
}
+255
View File
@@ -0,0 +1,255 @@
package handlers
import (
"fmt"
"net/http"
"strconv"
"time"
"simple_portal/middleware"
"simple_portal/models"
"simple_portal/session"
"github.com/gin-gonic/gin"
)
// HomeHandler renders the portal home page with enabled cards and search engine.
// 同时记录主页访问日志。
func HomeHandler(c *gin.Context) {
cards, err := models.GetEnabledCards()
if err != nil {
c.HTML(http.StatusInternalServerError, "home.html", gin.H{"Error": "Failed to load cards"})
return
}
searchEngine, err := models.GetSetting(models.SettingKeySearchEngine)
if err != nil {
searchEngine = models.SearchEngineGoogle
}
if searchEngine == "" {
searchEngine = models.SearchEngineGoogle
}
// Fetch homepage configuration
siteTitle, _ := models.GetSetting(models.SettingKeyHomepageTitle)
if siteTitle == "" {
siteTitle = models.DefaultHomepageTitle
}
siteSubtitle, _ := models.GetSetting(models.SettingKeyHomepageSubtitle)
backgroundImage, _ := models.GetSetting(models.SettingKeyHomepageBackground)
// 记录主页访问日志(异步,不影响页面渲染)
ip := c.ClientIP()
ua := c.Request.UserAgent()
referer := c.Request.Referer()
go func() {
_ = models.CreateAccessLog(ip, ua, models.ActionTypeVisit, "", referer)
}()
c.HTML(http.StatusOK, "home.html", gin.H{
"Title": siteTitle,
"Cards": cards,
"SearchEngine": searchEngine,
"SiteTitle": siteTitle,
"SiteSubtitle": siteSubtitle,
"BackgroundImage": backgroundImage,
})
}
// CardClickHandler 处理卡片点击,记录访问日志后重定向到目标URL。
func CardClickHandler(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
c.Redirect(http.StatusFound, "/")
return
}
card, err := models.GetCardByID(id)
if err != nil || card == nil {
c.Redirect(http.StatusFound, "/")
return
}
// 记录卡片点击日志
ip := c.ClientIP()
ua := c.Request.UserAgent()
referer := c.Request.Referer()
cardTitle := card.Title
go func() {
_ = models.CreateAccessLog(ip, ua, models.ActionTypeClick, cardTitle, referer)
}()
c.Redirect(http.StatusFound, card.URL)
}
// SearchHandler 处理搜索请求,记录搜索日志后重定向到搜索引擎。
func SearchHandler(c *gin.Context) {
query := c.Query("q")
if query == "" {
c.Redirect(http.StatusFound, "/")
return
}
searchEngine, err := models.GetSetting(models.SettingKeySearchEngine)
if err != nil || searchEngine == "" {
searchEngine = models.SearchEngineGoogle
}
// 记录搜索日志
ip := c.ClientIP()
ua := c.Request.UserAgent()
referer := c.Request.Referer()
go func() {
_ = models.CreateAccessLog(ip, ua, models.ActionTypeSearch, query, referer)
}()
// 重定向到搜索引擎
targetURL := fmt.Sprintf(searchEngine, query)
c.Redirect(http.StatusFound, targetURL)
}
// LoginGet renders the admin login page.
func LoginGet(c *gin.Context) {
// If already logged in, redirect to admin dashboard
if sessionID, err := c.Cookie("session_id"); err == nil && sessionID != "" {
store := c.MustGet("sessionStore").(*session.SessionStore)
if store.Get(sessionID) != nil {
c.Redirect(http.StatusFound, "/admin")
return
}
}
c.HTML(http.StatusOK, "admin/login.html", gin.H{
"Title": "登录",
"Error": "",
})
}
// LoginPost handles the login form submission.
// It includes login logging, IP ban checking, and automatic IP banning on repeated failures.
func LoginPost(c *gin.Context) {
username := c.PostForm("username")
password := c.PostForm("password")
clientIP := c.ClientIP()
userAgent := c.Request.UserAgent()
if username == "" || password == "" {
_ = models.CreateLoginLog(nil, username, clientIP, userAgent, false)
c.HTML(http.StatusOK, "admin/login.html", gin.H{
"Title": "登录",
"Error": "请输入用户名和密码",
})
return
}
// Check if IP is whitelisted — whitelist IPs bypass all ban checks
isWhitelisted, _ := models.IsIPWhitelisted(clientIP)
// Check if IP is currently banned (skip for whitelisted IPs)
if !isWhitelisted {
banned, ban, _ := models.IsIPBanned(clientIP)
if banned && ban != nil {
_ = models.CreateLoginLog(nil, username, clientIP, userAgent, false)
c.HTML(http.StatusOK, "admin/login.html", gin.H{
"Title": "登录",
"Error": fmt.Sprintf("您的IP已被封禁,解封时间:%s", ban.BannedUntil.Format("2006-01-02 15:04:05")),
})
return
}
}
matched, admin, err := models.VerifyPassword(username, password)
if err != nil {
_ = models.CreateLoginLog(nil, username, clientIP, userAgent, false)
c.HTML(http.StatusOK, "admin/login.html", gin.H{
"Title": "登录",
"Error": "登录失败,请重试",
})
return
}
if !matched || admin == nil {
_ = models.CreateLoginLog(nil, username, clientIP, userAgent, false)
// Record failure in IPBanGuard (skip for whitelisted IPs)
if !isWhitelisted {
guard := c.MustGet("ipBanGuard").(*middleware.IPBanGuard)
if guard.RecordFail(clientIP) {
// Auto-ban this IP for 30 minutes
_ = models.CreateIPBan(clientIP, "登录失败次数过多(自动封禁)", 5, time.Now().Add(30*time.Minute))
}
}
c.HTML(http.StatusOK, "admin/login.html", gin.H{
"Title": "登录",
"Error": "用户名或密码错误",
})
return
}
// Successful login
adminID := admin.ID
_ = models.CreateLoginLog(&adminID, username, clientIP, userAgent, true)
// Reset fail counter for this IP on successful login
if !isWhitelisted {
guard := c.MustGet("ipBanGuard").(*middleware.IPBanGuard)
guard.ResetFail(clientIP)
}
// Create session
store := c.MustGet("sessionStore").(*session.SessionStore)
sessionID := store.Create(admin.ID, admin.Username)
// Set cookie
c.SetCookie("session_id", sessionID, 86400, "/", "", false, true)
c.Redirect(http.StatusFound, "/admin")
}
// Logout handles logging out the current admin.
func Logout(c *gin.Context) {
sessionID, _ := c.Cookie("session_id")
if sessionID != "" {
store := c.MustGet("sessionStore").(*session.SessionStore)
store.Delete(sessionID)
}
// Clear cookie
c.SetCookie("session_id", "", -1, "/", "", false, true)
c.Redirect(http.StatusFound, "/admin/login")
}
// AdminIndex renders the admin dashboard.
// 包含访问统计数据和实时流量信息。
func AdminIndex(c *gin.Context) {
username, _ := c.Get("username")
// 获取访问统计
stats, err := models.GetAccessLogStats()
if err != nil {
stats = &models.AccessLogStats{}
}
// 获取最近访问记录(20条)
recentLogs, err := models.GetRecentAccessLogs(20)
if err != nil {
recentLogs = []models.AccessLog{}
}
// 获取IP统计(Top 10
ipStats, err := models.GetAccessLogStatsByIP(10)
if err != nil {
ipStats = nil
}
c.HTML(http.StatusOK, "admin/index.html", gin.H{
"Title": "管理后台",
"Username": username,
"Stats": stats,
"RecentLogs": recentLogs,
"IPStats": ipStats,
})
}
+258
View File
@@ -0,0 +1,258 @@
package handlers
import (
"net/http"
"strconv"
"simple_portal/models"
"simple_portal/session"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
)
// LoginLogsGet 渲染登录日志页面。
func LoginLogsGet(c *gin.Context) {
username, _ := c.Get("username")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
page = 1
}
pageSize := 20
logs, total, err := models.GetLoginLogs(page, pageSize)
if err != nil {
logs = []models.LoginLog{}
total = 0
}
// 获取活跃的封禁列表
bans, _ := models.GetAllActiveBans()
if bans == nil {
bans = []models.IPBan{}
}
totalPages := (total + pageSize - 1) / pageSize
if totalPages < 1 {
totalPages = 1
}
c.HTML(http.StatusOK, "admin/logs.html", gin.H{
"Title": "登录日志",
"Username": username,
"Logs": logs,
"Bans": bans,
"Page": page,
"TotalPages": totalPages,
"Total": total,
})
}
// UnbanIP 处理手动解封IP的请求。
func UnbanIP(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.Redirect(http.StatusFound, "/admin/logs")
return
}
_ = models.DeleteIPBan(id)
c.Redirect(http.StatusFound, "/admin/logs")
}
// ChangePasswordGet 渲染修改密码页面。
func ChangePasswordGet(c *gin.Context) {
username, _ := c.Get("username")
c.HTML(http.StatusOK, "admin/password.html", gin.H{
"Title": "修改密码",
"Username": username,
"Error": "",
"Message": "",
})
}
// ChangePasswordPost 处理修改密码表单提交。
func ChangePasswordPost(c *gin.Context) {
username, _ := c.Get("username")
adminID, _ := c.Get("adminID")
oldPassword := c.PostForm("old_password")
newPassword := c.PostForm("new_password")
confirmPassword := c.PostForm("confirm_password")
// 验证输入
if oldPassword == "" || newPassword == "" || confirmPassword == "" {
c.HTML(http.StatusOK, "admin/password.html", gin.H{
"Title": "修改密码",
"Username": username,
"Error": "请填写所有字段",
"Message": "",
})
return
}
if len(newPassword) < 6 {
c.HTML(http.StatusOK, "admin/password.html", gin.H{
"Title": "修改密码",
"Username": username,
"Error": "新密码长度不能少于6位",
"Message": "",
})
return
}
if newPassword != confirmPassword {
c.HTML(http.StatusOK, "admin/password.html", gin.H{
"Title": "修改密码",
"Username": username,
"Error": "两次输入的新密码不一致",
"Message": "",
})
return
}
// 验证旧密码
admin, err := models.GetAdminByUsername(username.(string))
if err != nil || admin == nil {
c.HTML(http.StatusOK, "admin/password.html", gin.H{
"Title": "修改密码",
"Username": username,
"Error": "用户不存在",
"Message": "",
})
return
}
if err := bcrypt.CompareHashAndPassword([]byte(admin.Password), []byte(oldPassword)); err != nil {
c.HTML(http.StatusOK, "admin/password.html", gin.H{
"Title": "修改密码",
"Username": username,
"Error": "旧密码不正确",
"Message": "",
})
return
}
// 生成新密码hash
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
c.HTML(http.StatusOK, "admin/password.html", gin.H{
"Title": "修改密码",
"Username": username,
"Error": "密码加密失败",
"Message": "",
})
return
}
// 更新密码
if err := models.ChangePassword(adminID.(int), string(hashedPassword)); err != nil {
c.HTML(http.StatusOK, "admin/password.html", gin.H{
"Title": "修改密码",
"Username": username,
"Error": "密码修改失败: " + err.Error(),
"Message": "",
})
return
}
// 记录登录日志
ip := c.ClientIP()
userAgent := c.Request.UserAgent()
adminIDInt := adminID.(int)
_ = models.CreateLoginLog(&adminIDInt, username.(string), ip, userAgent, true)
// 清除当前session,强制重新登录
sessionID, _ := c.Cookie("session_id")
if sessionID != "" {
store := c.MustGet("sessionStore").(*session.SessionStore)
store.Delete(sessionID)
}
c.SetCookie("session_id", "", -1, "/", "", false, true)
c.Redirect(http.StatusFound, "/admin/login")
}
// IPWhitelistGet 渲染IP白名单管理页面。
func IPWhitelistGet(c *gin.Context) {
username, _ := c.Get("username")
list, err := models.GetAllWhitelist()
if err != nil {
list = []models.IPWhitelist{}
}
hasWhitelist := len(list) > 0
c.HTML(http.StatusOK, "admin/ip_whitelist.html", gin.H{
"Title": "IP白名单",
"Username": username,
"Whitelist": list,
"HasWhitelist": hasWhitelist,
"Error": "",
"Message": "",
})
}
// IPWhitelistAdd 处理添加IP白名单的请求。
// 当白名单从空变为非空时,自动将当前操作者的IP也加入白名单,防止锁定自己。
func IPWhitelistAdd(c *gin.Context) {
username, _ := c.Get("username")
ip := c.PostForm("ip")
comment := c.PostForm("comment")
if ip == "" {
list, _ := models.GetAllWhitelist()
c.HTML(http.StatusOK, "admin/ip_whitelist.html", gin.H{
"Title": "IP白名单",
"Username": username,
"Whitelist": list,
"HasWhitelist": len(list) > 0,
"Error": "IP地址不能为空",
"Message": "",
})
return
}
// 检查白名单是否之前为空(首次启用白名单时需自动加入当前操作者IP)
wasEmpty, _ := models.HasWhitelist()
if err := models.AddWhitelist(ip, comment); err != nil {
list, _ := models.GetAllWhitelist()
c.HTML(http.StatusOK, "admin/ip_whitelist.html", gin.H{
"Title": "IP白名单",
"Username": username,
"Whitelist": list,
"HasWhitelist": len(list) > 0,
"Error": "添加失败: " + err.Error(),
"Message": "",
})
return
}
// 首次启用白名单:自动把当前操作者IP也加入,防止锁定
if !wasEmpty {
c.Redirect(http.StatusFound, "/admin/ip-whitelist")
return
}
currentIP := c.ClientIP()
// 检查当前IP是否和刚添加的一样
isAlreadyAdded, _ := models.IsIPWhitelisted(currentIP)
if !isAlreadyAdded {
_ = models.AddWhitelist(currentIP, "自动添加(当前操作者)")
}
c.Redirect(http.StatusFound, "/admin/ip-whitelist")
}
// IPWhitelistDelete 处理删除IP白名单的请求。
func IPWhitelistDelete(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.Redirect(http.StatusFound, "/admin/ip-whitelist")
return
}
_ = models.DeleteWhitelist(id)
c.Redirect(http.StatusFound, "/admin/ip-whitelist")
}
+141
View File
@@ -0,0 +1,141 @@
package handlers
import (
"net/http"
"strings"
"simple_portal/models"
"github.com/gin-gonic/gin"
)
// SettingsGet renders the admin settings page.
func SettingsGet(c *gin.Context) {
username, _ := c.Get("username")
searchEngine, err := models.GetSetting(models.SettingKeySearchEngine)
if err != nil {
searchEngine = models.SearchEngineGoogle
}
if searchEngine == "" {
searchEngine = models.SearchEngineGoogle
}
engines := map[string]string{
"Google": models.SearchEngineGoogle,
"Bing": models.SearchEngineBing,
"百度": models.SearchEngineBaidu,
}
// Fetch homepage configuration
homepageTitle, _ := models.GetSetting(models.SettingKeyHomepageTitle)
if homepageTitle == "" {
homepageTitle = models.DefaultHomepageTitle
}
homepageSubtitle, _ := models.GetSetting(models.SettingKeyHomepageSubtitle)
homepageBackground, _ := models.GetSetting(models.SettingKeyHomepageBackground)
c.HTML(http.StatusOK, "admin/settings.html", gin.H{
"Title": "设置",
"Username": username,
"SearchEngine": searchEngine,
"Engines": engines,
"HomepageTitle": homepageTitle,
"HomepageSubtitle": homepageSubtitle,
"HomepageBackground": homepageBackground,
})
}
// SettingsPost handles the settings form submission.
func SettingsPost(c *gin.Context) {
username, _ := c.Get("username")
searchEngine := c.PostForm("search_engine")
customURL := c.PostForm("custom_url")
// 如果用户填写了自定义URL,优先使用
if customURL != "" {
if !strings.Contains(customURL, "%s") {
engines := map[string]string{
"Google": models.SearchEngineGoogle,
"Bing": models.SearchEngineBing,
"百度": models.SearchEngineBaidu,
}
homepageTitle := c.PostForm("homepage_title")
if homepageTitle == "" {
homepageTitle = models.DefaultHomepageTitle
}
homepageSubtitle := c.PostForm("homepage_subtitle")
homepageBackground := c.PostForm("homepage_background")
c.HTML(http.StatusOK, "admin/settings.html", gin.H{
"Title": "设置",
"Username": username,
"SearchEngine": searchEngine,
"Engines": engines,
"HomepageTitle": homepageTitle,
"HomepageSubtitle": homepageSubtitle,
"HomepageBackground": homepageBackground,
"Error": "自定义 URL 必须包含 %s 作为搜索词占位符",
})
return
}
searchEngine = customURL
}
if searchEngine == "" {
searchEngine = models.SearchEngineGoogle
}
// Save search engine setting
if err := models.SetSetting(models.SettingKeySearchEngine, searchEngine); err != nil {
engines := map[string]string{
"Google": models.SearchEngineGoogle,
"Bing": models.SearchEngineBing,
"百度": models.SearchEngineBaidu,
}
homepageTitle := c.PostForm("homepage_title")
if homepageTitle == "" {
homepageTitle = models.DefaultHomepageTitle
}
homepageSubtitle := c.PostForm("homepage_subtitle")
homepageBackground := c.PostForm("homepage_background")
c.HTML(http.StatusOK, "admin/settings.html", gin.H{
"Title": "设置",
"Username": username,
"SearchEngine": searchEngine,
"Engines": engines,
"HomepageTitle": homepageTitle,
"HomepageSubtitle": homepageSubtitle,
"HomepageBackground": homepageBackground,
"Error": "保存失败,请重试",
})
return
}
// Save homepage settings
homepageTitle := c.PostForm("homepage_title")
if homepageTitle == "" {
homepageTitle = models.DefaultHomepageTitle
}
homepageSubtitle := c.PostForm("homepage_subtitle")
homepageBackground := c.PostForm("homepage_background")
if err := models.SetSetting(models.SettingKeyHomepageTitle, homepageTitle); err != nil {
c.Redirect(http.StatusFound, "/admin/settings")
return
}
if err := models.SetSetting(models.SettingKeyHomepageSubtitle, homepageSubtitle); err != nil {
c.Redirect(http.StatusFound, "/admin/settings")
return
}
if err := models.SetSetting(models.SettingKeyHomepageBackground, homepageBackground); err != nil {
c.Redirect(http.StatusFound, "/admin/settings")
return
}
// 保存成功后重定向,利用 PRG 模式
c.Redirect(http.StatusFound, "/admin/settings")
}
+269
View File
@@ -0,0 +1,269 @@
package handlers
import (
"fmt"
"image"
"image/jpeg"
"io"
"os"
"path/filepath"
"strings"
"github.com/disintegration/imaging"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// allowedMIMETypes defines the permitted upload file MIME types.
var allowedMIMETypes = map[string]string{
"image/jpeg": ".jpg",
"image/png": ".png",
"image/gif": ".gif",
}
// maxUploadSize is the maximum allowed file size in bytes (5 MB).
const maxUploadSize = 5 << 20
// uploadDir is the directory where uploaded files are stored.
const uploadDir = "./data/uploads"
// thumbSuffix is the suffix appended to compressed image filenames.
const thumbSuffix = "_thumb"
// UploadResponse is the JSON response returned after a successful upload.
type UploadResponse struct {
URL string `json:"url"`
}
// UploadHandler handles image upload requests.
// POST /admin/upload
// Accepts multipart form with "file" field and optional "type" parameter ("icon" or "background").
// Returns JSON: {"url": "/uploads/{uuid}.{ext}"}
func UploadHandler(c *gin.Context) {
// Parse multipart form with size limit
if err := c.Request.ParseMultipartForm(maxUploadSize); err != nil {
c.JSON(400, gin.H{"error": "文件太大,最大允许 5MB"})
return
}
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(400, gin.H{"error": "请选择要上传的文件"})
return
}
defer file.Close()
// Validate file size
if header.Size > maxUploadSize {
c.JSON(400, gin.H{"error": "文件太大,最大允许 5MB"})
return
}
// Validate MIME type by reading first 512 bytes
buf := make([]byte, 512)
n, err := file.Read(buf)
if err != nil && err != io.EOF {
c.JSON(500, gin.H{"error": "读取文件失败"})
return
}
// Seek back to start
if _, err := file.Seek(0, 0); err != nil {
c.JSON(500, gin.H{"error": "处理文件失败"})
return
}
mimeType := strings.Split(c.Request.Header.Get("Content-Type"), ";")[0]
// Also detect from file content
detectedType := detectMIMEType(buf[:n])
if detectedType == "" {
detectedType = mimeType
}
ext, ok := allowedMIMETypes[detectedType]
if !ok {
// Try the header's content type as fallback
ext, ok = allowedMIMETypes[mimeType]
if !ok {
c.JSON(400, gin.H{"error": "不支持的文件格式,仅支持 jpg, png, gif"})
return
}
}
// Generate UUID filename
fileUUID := uuid.New().String()
filename := fileUUID + ext
// Ensure upload directory exists
if err := os.MkdirAll(uploadDir, 0755); err != nil {
c.JSON(500, gin.H{"error": "创建上传目录失败"})
return
}
// Save original file
originalPath := filepath.Join(uploadDir, filename)
dst, err := os.Create(originalPath)
if err != nil {
c.JSON(500, gin.H{"error": "保存文件失败"})
return
}
defer dst.Close()
if _, err := io.Copy(dst, file); err != nil {
os.Remove(originalPath)
c.JSON(500, gin.H{"error": "保存文件失败"})
return
}
dst.Close()
// Generate compressed thumbnail
uploadType := c.PostForm("type")
if err := generateThumbnail(originalPath, fileUUID, uploadType); err != nil {
// Log the error but don't fail the upload — thumbnail is optional
fmt.Printf("Warning: failed to generate thumbnail for %s: %v\n", filename, err)
}
url := "/uploads/" + filename
c.JSON(200, UploadResponse{URL: url})
}
// ServeUploadHandler serves uploaded files.
// GET /uploads/:filename
// If query param thumb=1, returns the compressed version.
func ServeUploadHandler(c *gin.Context) {
filename := c.Param("filename")
if filename == "" {
c.Status(404)
return
}
// Prevent directory traversal
if strings.Contains(filename, "..") || strings.Contains(filename, "/") || strings.Contains(filename, "\\") {
c.Status(404)
return
}
filePath := filepath.Join(uploadDir, filename)
// Check if thumb=1 query parameter is requested
if c.Query("thumb") == "1" {
// Try to serve the thumbnail version
ext := filepath.Ext(filename)
baseName := filename[:len(filename)-len(ext)]
thumbPath := filepath.Join(uploadDir, baseName+thumbSuffix+".jpg")
if _, err := os.Stat(thumbPath); err == nil {
c.File(thumbPath)
return
}
// If thumbnail doesn't exist, fall through to serve original
}
// Serve original file
if _, err := os.Stat(filePath); os.IsNotExist(err) {
c.Status(404)
return
}
c.File(filePath)
}
// detectMIMEType detects the MIME type from file content bytes.
func detectMIMEType(data []byte) string {
if len(data) < 3 {
return ""
}
// JPEG: starts with FF D8 FF
if data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF {
return "image/jpeg"
}
// PNG: starts with 89 50 4E 47
if len(data) >= 4 && data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 {
return "image/png"
}
// GIF: starts with "GIF"
if len(data) >= 3 && data[0] == 'G' && data[1] == 'I' && data[2] == 'F' {
return "image/gif"
}
return ""
}
// generateThumbnail creates a compressed version of the uploaded image.
// Icon type: max 200x200, quality 80
// Background type: max 1920x1080, quality 85
// Default: max 800x600, quality 85
// Thumbnails are saved as JPG format.
func generateThumbnail(originalPath, fileUUID, uploadType string) error {
// Open the original image
img, err := imaging.Open(originalPath)
if err != nil {
return fmt.Errorf("failed to open image for thumbnail: %w", err)
}
var maxWidth, maxHeight int
var quality int
switch uploadType {
case "icon":
maxWidth = 200
maxHeight = 200
quality = 80
case "background":
maxWidth = 1920
maxHeight = 1080
quality = 85
default:
maxWidth = 800
maxHeight = 600
quality = 85
}
// Resize if needed, maintaining aspect ratio
thumb := fitImage(img, maxWidth, maxHeight)
// Save thumbnail as JPEG
thumbPath := filepath.Join(uploadDir, fileUUID+thumbSuffix+".jpg")
thumbFile, err := os.Create(thumbPath)
if err != nil {
return fmt.Errorf("failed to create thumbnail file: %w", err)
}
defer thumbFile.Close()
if err := jpeg.Encode(thumbFile, thumb, &jpeg.Options{Quality: quality}); err != nil {
os.Remove(thumbPath)
return fmt.Errorf("failed to encode thumbnail: %w", err)
}
return nil
}
// fitImage resizes the image to fit within maxWidth x maxHeight while
// maintaining aspect ratio. If the image is already smaller, it is returned as-is.
func fitImage(img image.Image, maxWidth, maxHeight int) image.Image {
bounds := img.Bounds()
w := bounds.Dx()
h := bounds.Dy()
if w <= maxWidth && h <= maxHeight {
return img
}
// Calculate the scaling factor to fit within bounds
scaleW := float64(maxWidth) / float64(w)
scaleH := float64(maxHeight) / float64(h)
scale := scaleW
if scaleH < scaleW {
scale = scaleH
}
newW := int(float64(w) * scale)
newH := int(float64(h) * scale)
if newW < 1 {
newW = 1
}
if newH < 1 {
newH = 1
}
return imaging.Resize(img, newW, newH, imaging.Lanczos)
}