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:
@@ -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.
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user