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
+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,
})
}