From c16a8dfbc46baf0f7c62ab8c76bfcaed23cc0e36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E6=96=87=E5=B3=B0?= Date: Thu, 28 May 2026 13:54:07 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=97=A8=E6=88=B7=E7=BD=91=E7=AB=99?= =?UTF-8?q?=E5=88=9D=E5=A7=8B=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Go + Gin + html/template 服务端渲染 - 主页:Google 风格搜索框 + 导航卡片 - 后台:卡片 CRUD、搜索引擎配置、主页背景/标题配置 - 图片上传:支持 jpg/jpeg/png/gif,自动压缩,缩略图参数 ?thumb=1 - 安全:登录日志、修改密码、IP 自动封禁、IP 白名单 - 访问统计:主页访问/卡片点击/搜索追踪、实时流量、IP 统计 - SQLite 存储(modernc.org/sqlite,纯 Go) - 内存 Session + bcrypt 密码哈希 --- .gitignore | 33 ++ database/db.go | 181 +++++++ docs/class-diagram.mermaid | 108 ++++ docs/sequence-cards-crud.mermaid | 53 ++ docs/sequence-home.mermaid | 18 + docs/sequence-login.mermaid | 25 + docs/sequence-search.mermaid | 10 + docs/system_design.md | 673 +++++++++++++++++++++++++ go.mod | 46 ++ go.sum | 134 +++++ handlers/admin.go | 5 + handlers/analytics.go | 46 ++ handlers/cards.go | 197 ++++++++ handlers/home.go | 255 ++++++++++ handlers/security.go | 258 ++++++++++ handlers/settings.go | 141 ++++++ handlers/upload.go | 269 ++++++++++ main.go | 161 ++++++ middleware/auth.go | 35 ++ middleware/ipguard.go | 116 +++++ models/accesslog.go | 193 ++++++++ models/admin.go | 75 +++ models/card.go | 224 +++++++++ models/ipban.go | 79 +++ models/ipwhitelist.go | 77 +++ models/loginlog.go | 73 +++ models/setting.go | 55 +++ session/session.go | 65 +++ static/style.css | 792 ++++++++++++++++++++++++++++++ static/upload.js | 140 ++++++ templates/admin/403.html | 11 + templates/admin/access_logs.html | 97 ++++ templates/admin/card_form.html | 64 +++ templates/admin/cards.html | 82 ++++ templates/admin/index.html | 109 ++++ templates/admin/ip_whitelist.html | 85 ++++ templates/admin/login.html | 21 + templates/admin/logs.html | 105 ++++ templates/admin/password.html | 49 ++ templates/admin/settings.html | 80 +++ templates/home.html | 39 ++ templates/layout.html | 16 + 42 files changed, 5295 insertions(+) create mode 100644 .gitignore create mode 100644 database/db.go create mode 100644 docs/class-diagram.mermaid create mode 100644 docs/sequence-cards-crud.mermaid create mode 100644 docs/sequence-home.mermaid create mode 100644 docs/sequence-login.mermaid create mode 100644 docs/sequence-search.mermaid create mode 100644 docs/system_design.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 handlers/admin.go create mode 100644 handlers/analytics.go create mode 100644 handlers/cards.go create mode 100644 handlers/home.go create mode 100644 handlers/security.go create mode 100644 handlers/settings.go create mode 100644 handlers/upload.go create mode 100644 main.go create mode 100644 middleware/auth.go create mode 100644 middleware/ipguard.go create mode 100644 models/accesslog.go create mode 100644 models/admin.go create mode 100644 models/card.go create mode 100644 models/ipban.go create mode 100644 models/ipwhitelist.go create mode 100644 models/loginlog.go create mode 100644 models/setting.go create mode 100644 session/session.go create mode 100644 static/style.css create mode 100644 static/upload.js create mode 100644 templates/admin/403.html create mode 100644 templates/admin/access_logs.html create mode 100644 templates/admin/card_form.html create mode 100644 templates/admin/cards.html create mode 100644 templates/admin/index.html create mode 100644 templates/admin/ip_whitelist.html create mode 100644 templates/admin/login.html create mode 100644 templates/admin/logs.html create mode 100644 templates/admin/password.html create mode 100644 templates/admin/settings.html create mode 100644 templates/home.html create mode 100644 templates/layout.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..31bf06e --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# 编译产物 +*.exe +portal +/portal + +# 依赖 +/vendor/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db +Desktop.ini + +# 数据库 +/data/*.db +/data/*.db-shm +/data/*.db-wal + +# 用户上传文件 +/data/uploads/ + +# 测试临时文件 +/test/ + +# WorkBuddy +.workbuddy/ diff --git a/database/db.go b/database/db.go new file mode 100644 index 0000000..ea967ac --- /dev/null +++ b/database/db.go @@ -0,0 +1,181 @@ +package database + +import ( + "database/sql" + "fmt" + "os" + "path/filepath" + + _ "modernc.org/sqlite" +) + +// DB is the global database connection pointer. +var DB *sql.DB + +// InitDB initializes the SQLite database, creates the data directory, +// opens the connection, sets WAL mode, and creates default tables and data. +func InitDB() error { + dbPath := filepath.Join(".", "data", "portal.db") + + // Create data directory if not exists + if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil { + return fmt.Errorf("failed to create data directory: %w", err) + } + + var err error + DB, err = sql.Open("sqlite", dbPath) + if err != nil { + return fmt.Errorf("failed to open database: %w", err) + } + + // SQLite requires max open conns = 1 for safe writes + DB.SetMaxOpenConns(1) + + // Enable WAL mode for better concurrent read performance + if _, err := DB.Exec("PRAGMA journal_mode=WAL"); err != nil { + return fmt.Errorf("failed to set WAL mode: %w", err) + } + + // Create tables + if err := createTables(); err != nil { + return fmt.Errorf("failed to create tables: %w", err) + } + + // Seed default data + if err := seedData(); err != nil { + return fmt.Errorf("failed to seed data: %w", err) + } + + return nil +} + +// createTables creates the cards, settings, and admins tables. +func createTables() error { + _, err := DB.Exec(` + CREATE TABLE IF NOT EXISTS cards ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + icon TEXT, + title TEXT NOT NULL, + subtitle TEXT, + url TEXT NOT NULL, + sort INTEGER DEFAULT 0, + enabled INTEGER DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT + ); + CREATE TABLE IF NOT EXISTS admins ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS login_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + admin_id INTEGER, + username TEXT NOT NULL, + ip TEXT NOT NULL, + user_agent TEXT, + success INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE IF NOT EXISTS ip_bans ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ip TEXT NOT NULL, + reason TEXT, + fail_count INTEGER DEFAULT 0, + banned_until DATETIME NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE IF NOT EXISTS ip_whitelist ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ip TEXT NOT NULL, + comment TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE IF NOT EXISTS access_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ip TEXT NOT NULL, + user_agent TEXT, + action_type TEXT NOT NULL, + detail TEXT DEFAULT '', + referer TEXT DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + CREATE INDEX IF NOT EXISTS idx_access_logs_ip ON access_logs(ip); + CREATE INDEX IF NOT EXISTS idx_access_logs_action_type ON access_logs(action_type); + CREATE INDEX IF NOT EXISTS idx_access_logs_created_at ON access_logs(created_at); + `) + return err +} + +// seedData inserts default admin account and search engine setting if not present. +func seedData() error { + // Insert default admin if not exists + var count int + err := DB.QueryRow("SELECT COUNT(*) FROM admins WHERE username = ?", "admin").Scan(&count) + if err != nil { + return err + } + if count == 0 { + _, err = DB.Exec( + "INSERT INTO admins (username, password) VALUES (?, ?)", + "admin", + "$2a$10$h3Csm2HmWUtvim3MJ8VG0OHx/tevZorlUXQVDtN2EgWhROtiM3Sg.", // bcrypt hash for "admin123" + ) + if err != nil { + return err + } + } + + // Insert default search engine setting if not exists + var settingCount int + err = DB.QueryRow("SELECT COUNT(*) FROM settings WHERE key = ?", "search_engine").Scan(&settingCount) + if err != nil { + return err + } + if settingCount == 0 { + _, err = DB.Exec( + "INSERT INTO settings (key, value) VALUES (?, ?)", + "search_engine", + "https://www.google.com/search?q=%s", + ) + if err != nil { + return err + } + } + + // Insert default homepage settings if not exists + defaultSettings := []struct { + key string + value string + }{ + {"homepage_title", "Portal"}, + {"homepage_subtitle", ""}, + {"homepage_background", ""}, + } + for _, s := range defaultSettings { + var cnt int + err = DB.QueryRow("SELECT COUNT(*) FROM settings WHERE key = ?", s.key).Scan(&cnt) + if err != nil { + return err + } + if cnt == 0 { + _, err = DB.Exec("INSERT INTO settings (key, value) VALUES (?, ?)", s.key, s.value) + if err != nil { + return err + } + } + } + + return nil +} + +// CloseDB closes the database connection. +func CloseDB() error { + if DB != nil { + return DB.Close() + } + return nil +} diff --git a/docs/class-diagram.mermaid b/docs/class-diagram.mermaid new file mode 100644 index 0000000..c78f792 --- /dev/null +++ b/docs/class-diagram.mermaid @@ -0,0 +1,108 @@ +classDiagram + direction LR + + class Card { + +int ID + +string Icon + +string Title + +string Subtitle + +string URL + +int Sort + +bool Enabled + +time Time CreatedAt + } + + class Setting { + +string Key + +string Value + } + + class Admin { + +int ID + +string Username + +string Password + } + + class SessionData { + +int AdminID + +string Username + +time Time CreatedAt + } + + class SessionStore { + -map~string-SessionData~ store + -sync.RWMutex mu + +Create(adminID int, username string) string + +Get(sessionID string) *SessionData + +Delete(sessionID string) + } + + class CardModel { + +GetAllCards() []Card + +GetEnabledCards() []Card + +GetCardByID(id int) *Card + +CreateCard(card *Card) error + +UpdateCard(card *Card) error + +DeleteCard(id int) error + +ToggleCard(id int) error + +MoveCardUp(id int) error + +MoveCardDown(id int) error + } + + class SettingModel { + +GetSetting(key string) string + +SetSetting(key string, value string) error + +GetSearchEngines() map~string-string + +SetSearchEngine(name string, url string) error + } + + class AdminModel { + +GetAdminByUsername(username string) *Admin + +CreateAdmin(username string, password string) error + +VerifyPassword(username string, password string) bool + +ChangePassword(adminID int, newPassword string) error + } + + class HandlerHome { + +HomeHandler(c *gin.Context) + } + + class HandlerAdmin { + +LoginGet(c *gin.Context) + +LoginPost(c *gin.Context) + +Logout(c *gin.Context) + +AdminIndex(c *gin.Context) + } + + class HandlerCards { + +CardsList(c *gin.Context) + +CardCreateGet(c *gin.Context) + +CardCreatePost(c *gin.Context) + +CardEditGet(c *gin.Context) + +CardEditPost(c *gin.Context) + +CardDelete(c *gin.Context) + +CardToggle(c *gin.Context) + +CardMoveUp(c *gin.Context) + +CardMoveDown(c *gin.Context) + } + + class HandlerSettings { + +SettingsGet(c *gin.Context) + +SettingsPost(c *gin.Context) + } + + class AuthMiddleware { + +AuthRequired() gin.HandlerFunc + } + + CardModel --> Card : returns + SettingModel --> Setting : returns + AdminModel --> Admin : returns + SessionStore --> SessionData : stores + HandlerHome --> CardModel : uses + HandlerHome --> SettingModel : uses + HandlerAdmin --> AdminModel : uses + HandlerAdmin --> SessionStore : uses + HandlerCards --> CardModel : uses + HandlerSettings --> SettingModel : uses + AuthMiddleware --> SessionStore : uses diff --git a/docs/sequence-cards-crud.mermaid b/docs/sequence-cards-crud.mermaid new file mode 100644 index 0000000..bd14cce --- /dev/null +++ b/docs/sequence-cards-crud.mermaid @@ -0,0 +1,53 @@ +sequenceDiagram + participant U as 管理员浏览器 + participant G as Gin Router + participant MW as AuthMiddleware + participant H as HandlerCards + participant CM as CardModel + + Note over U,CM: 卡片列表 + U->>G: GET /admin/cards + G->>MW: AuthRequired() + MW->>MW: 检查 Cookie session_id + alt 未登录 + MW-->>U: 302 → /admin/login + else 已登录 + MW->>H: CardsList(c) + H->>CM: GetAllCards() + CM-->>H: []Card + H-->>U: 卡片列表 HTML + end + + Note over U,CM: 新增卡片 + U->>G: GET /admin/cards/new + G->>H: CardCreateGet(c) + H-->>U: 空表单 HTML + + U->>G: POST /admin/cards {icon, title, subtitle, url} + G->>H: CardCreatePost(c) + H->>CM: CreateCard(&Card{...}) + CM->>CM: INSERT INTO cards(...) + CM-->>H: nil + H-->>U: 302 Redirect → /admin/cards + + Note over U,CM: 编辑卡片 + U->>G: GET /admin/cards/:id/edit + G->>H: CardEditGet(c) + H->>CM: GetCardByID(id) + CM-->>H: *Card + H-->>U: 预填表单 HTML + + U->>G: POST /admin/cards/:id {icon, title, subtitle, url} + G->>H: CardEditPost(c) + H->>CM: UpdateCard(&Card{...}) + CM->>CM: UPDATE cards SET ... WHERE id=? + CM-->>H: nil + H-->>U: 302 Redirect → /admin/cards + + Note over U,CM: 删除卡片 + U->>G: POST /admin/cards/:id/delete + G->>H: CardDelete(c) + H->>CM: DeleteCard(id) + CM->>CM: DELETE FROM cards WHERE id=? + CM-->>H: nil + H-->>U: 302 Redirect → /admin/cards diff --git a/docs/sequence-home.mermaid b/docs/sequence-home.mermaid new file mode 100644 index 0000000..cacd20e --- /dev/null +++ b/docs/sequence-home.mermaid @@ -0,0 +1,18 @@ +sequenceDiagram + participant U as 用户浏览器 + participant G as Gin Router + participant H as HomeHandler + participant CM as CardModel + participant SM as SettingModel + participant T as html/template + + U->>G: GET / + G->>H: HomeHandler(c) + H->>CM: GetEnabledCards() + CM->>CM: SELECT * FROM cards WHERE enabled=1 ORDER BY sort + CM-->>H: []Card + H->>SM: GetSetting("search_engine") + SM->>SM: SELECT value FROM settings WHERE key='search_engine' + SM-->>H: search URL template + H->>T: ExecuteTemplate("home.html", data) + T-->>U: HTML (搜索框 + 卡片网格) diff --git a/docs/sequence-login.mermaid b/docs/sequence-login.mermaid new file mode 100644 index 0000000..a08f922 --- /dev/null +++ b/docs/sequence-login.mermaid @@ -0,0 +1,25 @@ +sequenceDiagram + participant U as 用户浏览器 + participant G as Gin Router + participant H as HandlerAdmin + participant AM as AdminModel + participant SS as SessionStore + + U->>G: GET /admin/login + G->>H: LoginGet(c) + H-->>U: 登录页 HTML + + U->>G: POST /admin/login {username, password} + G->>H: LoginPost(c) + H->>AM: VerifyPassword(username, password) + AM->>AM: bcrypt.CompareHashAndPassword() + AM-->>H: true/false + + alt 验证成功 + H->>SS: Create(adminID, username) + SS-->>H: sessionID + H->>H: c.SetCookie("session_id", sessionID, 86400, "/", "", false, true) + H-->>U: 302 Redirect → /admin + else 验证失败 + H-->>U: 200 + 登录页(错误提示) + end diff --git a/docs/sequence-search.mermaid b/docs/sequence-search.mermaid new file mode 100644 index 0000000..82bca7b --- /dev/null +++ b/docs/sequence-search.mermaid @@ -0,0 +1,10 @@ +sequenceDiagram + participant U as 用户浏览器 + participant JS as 前端 JavaScript + participant SE as 搜索引擎 + + U->>JS: 输入关键词,按回车/点击搜索 + JS->>JS: 读取隐藏域 search_url_template + JS->>JS: fmt.Sprintf(template, query) + JS->>U: window.location.href = 拼接后的URL + U->>SE: HTTP 请求到搜索引擎 diff --git a/docs/system_design.md b/docs/system_design.md new file mode 100644 index 0000000..52a793c --- /dev/null +++ b/docs/system_design.md @@ -0,0 +1,673 @@ +# Simple Portal — 系统架构设计文档 + +## Part A: 系统设计 + +--- + +### 1. 实现方案分析 + +#### 核心技术挑战 + +| 挑战 | 解决方案 | +|------|---------| +| 纯服务端渲染,无 SPA 路由 | Gin 路由 + html/template 模板继承(layout 嵌套子模板),表单 POST 提交后 302 重定向 | +| SQLite 并发写入 | 单文件 SQLite + WAL 模式,Go 侧使用全局 `*sql.DB` 连接池(`SetMaxOpenConns(1)` 保证串行写) | +| Session 管理(无需外部依赖) | 内存 `map[string]*Session` + `sync.RWMutex`,Cookie 存储 session ID,重启失效可接受 | +| 卡片排序 | `sort` 字段整数排序,上移/下移通过交换相邻卡片的 sort 值实现 | +| 搜索引擎占位符 | settings 表存储引擎 URL 模板(`%s` 占位符),前端 JS 拼接跳转,后端验证模板合法性 | + +#### 框架与库选型 + +| 组件 | 选型 | 理由 | +|------|------|------| +| HTTP 框架 | `github.com/gin-gonic/gin` | PRD 指定 | +| SQLite 驱动 | `modernc.org/sqlite` | 纯 Go 实现,无需 CGO,交叉编译友好 | +| 密码哈希 | `golang.org/x/crypto/bcrypt` | PRD 指定,工业标准 | +| 模板引擎 | `html/template` | PRD 指定,Go 原生 | +| CSS | 单文件 `style.css` | PRD 约束,无 CSS 框架 | + +#### 架构模式 + +采用 **经典 MVC 分层**: + +``` +Request → Gin Router → Middleware(auth) → Handler → Model → SQLite + ↓ + Template Rendering → HTML Response +``` + +- **Model**:纯数据访问层,封装 SQL 操作 +- **Handler**(Controller):处理 HTTP 请求,调用 Model,渲染模板 +- **Template**(View):html/template 模板文件 +- **Middleware**:鉴权拦截,未登录重定向到登录页 +- **Session**:独立包,提供 Create/Get/Delete 操作 + +--- + +### 2. 文件列表 + +``` +Portal_page/ +├── main.go # 程序入口:初始化 DB、路由、启动服务 +├── go.mod # Go 模块定义 +├── go.sum # 依赖校验 +│ +├── database/ +│ └── db.go # DB 初始化、表创建、全局 *sql.DB +│ +├── models/ +│ ├── card.go # Card CRUD + 排序操作 +│ ├── setting.go # Setting 读写(搜索引擎配置等) +│ └── admin.go # Admin 用户操作 + 密码验证 +│ +├── handlers/ +│ ├── home.go # 主页渲染(卡片列表 + 搜索框) +│ ├── admin.go # 登录/登出 + 后台首页 +│ ├── cards.go # 卡片增删改查 + 启用/禁用 + 排序 +│ └── settings.go # 搜索引擎配置页面 + 保存 +│ +├── middleware/ +│ └── auth.go # 登录鉴权中间件 +│ +├── session/ +│ └── session.go # 内存 Session 管理 +│ +├── templates/ +│ ├── layout.html # 基础布局(head/nav/footer) +│ ├── home.html # 主页模板 +│ └── admin/ +│ ├── login.html # 登录页 +│ ├── index.html # 后台首页(仪表盘) +│ ├── cards.html # 卡片列表管理 +│ ├── card_form.html # 卡片新增/编辑表单 +│ └── settings.html # 搜索引擎设置 +│ +└── static/ + └── style.css # 全局样式 +``` + +#### 各文件职责说明 + +| 文件 | 职责 | +|------|------| +| `main.go` | 加载配置、初始化 DB、注册路由和中间件、启动 HTTP 服务 | +| `database/db.go` | `InitDB()` 打开 SQLite 连接、建表、插入默认管理员和默认搜索引擎配置 | +| `models/card.go` | `Card` struct;`GetAllCards()`/`GetEnabledCards()`/`CreateCard()`/`UpdateCard()`/`DeleteCard()`/`ToggleCard()`/`MoveCardUp()`/`MoveCardDown()` | +| `models/setting.go` | `Setting` struct;`GetSetting(key)`/`SetSetting(key, value)`;预定义搜索引擎常量 | +| `models/admin.go` | `Admin` struct;`GetAdminByUsername()`/`CreateAdmin()`/`VerifyPassword()`/`ChangePassword()` | +| `handlers/home.go` | `HomeHandler`:查询启用卡片 + 当前搜索引擎配置,渲染主页 | +| `handlers/admin.go` | `LoginGet`/`LoginPost`/`Logout`/`AdminIndex`:登录表单、认证、登出、后台首页 | +| `handlers/cards.go` | `CardsList`/`CardCreate`/`CardEdit`/`CardDelete`/`CardToggle`/`CardMoveUp`/`CardMoveDown` | +| `handlers/settings.go` | `SettingsGet`/`SettingsPost`:展示/保存搜索引擎配置 | +| `middleware/auth.go` | `AuthRequired()`:检查 Cookie 中的 session_id,未登录则 302 到 /admin/login | +| `session/session.go` | `SessionStore`:内存 map + RWMutex;`Create()/Get()/Delete()` | +| `templates/layout.html` | HTML 骨架:`{{define "layout"}}...{{template "content" .}}{{end}}` | +| `templates/home.html` | 搜索框 + 卡片网格 | +| `templates/admin/*.html` | 后台各页面 | +| `static/style.css` | 全局 CSS:卡片网格、搜索框、表单、后台布局 | + +--- + +### 3. 数据结构与接口 + +```mermaid +classDiagram + direction LR + + class Card { + +int ID + +string Icon + +string Title + +string Subtitle + +string URL + +int Sort + +bool Enabled + +time Time CreatedAt + } + + class Setting { + +string Key + +string Value + } + + class Admin { + +int ID + +string Username + +string Password + } + + class SessionData { + +int AdminID + +string Username + +time Time CreatedAt + } + + class SessionStore { + -map~string-SessionData~ store + -sync.RWMutex mu + +Create(adminID int, username string) string + +Get(sessionID string) *SessionData + +Delete(sessionID string) + } + + class CardModel { + +GetAllCards() []Card + +GetEnabledCards() []Card + +GetCardByID(id int) *Card + +CreateCard(card *Card) error + +UpdateCard(card *Card) error + +DeleteCard(id int) error + +ToggleCard(id int) error + +MoveCardUp(id int) error + +MoveCardDown(id int) error + } + + class SettingModel { + +GetSetting(key string) string + +SetSetting(key string, value string) error + +GetSearchEngines() map~string-string + +SetSearchEngine(name string, url string) error + } + + class AdminModel { + +GetAdminByUsername(username string) *Admin + +CreateAdmin(username string, password string) error + +VerifyPassword(username string, password string) bool + +ChangePassword(adminID int, newPassword string) error + } + + class HandlerHome { + +HomeHandler(c *gin.Context) + } + + class HandlerAdmin { + +LoginGet(c *gin.Context) + +LoginPost(c *gin.Context) + +Logout(c *gin.Context) + +AdminIndex(c *gin.Context) + } + + class HandlerCards { + +CardsList(c *gin.Context) + +CardCreateGet(c *gin.Context) + +CardCreatePost(c *gin.Context) + +CardEditGet(c *gin.Context) + +CardEditPost(c *gin.Context) + +CardDelete(c *gin.Context) + +CardToggle(c *gin.Context) + +CardMoveUp(c *gin.Context) + +CardMoveDown(c *gin.Context) + } + + class HandlerSettings { + +SettingsGet(c *gin.Context) + +SettingsPost(c *gin.Context) + } + + class AuthMiddleware { + +AuthRequired() gin.HandlerFunc + } + + CardModel --> Card : returns + SettingModel --> Setting : returns + AdminModel --> Admin : returns + SessionStore --> SessionData : stores + HandlerHome --> CardModel : uses + HandlerHome --> SettingModel : uses + HandlerAdmin --> AdminModel : uses + HandlerAdmin --> SessionStore : uses + HandlerCards --> CardModel : uses + HandlerSettings --> SettingModel : uses + AuthMiddleware --> SessionStore : uses +``` + +#### 路由表 + +| 方法 | 路径 | Handler | 中间件 | 说明 | +|------|------|---------|--------|------| +| GET | `/` | HomeHandler | — | 主页 | +| GET | `/admin/login` | LoginGet | — | 登录页 | +| POST | `/admin/login` | LoginPost | — | 登录提交 | +| POST | `/admin/logout` | Logout | AuthRequired | 登出 | +| GET | `/admin` | AdminIndex | AuthRequired | 后台首页 | +| GET | `/admin/cards` | CardsList | AuthRequired | 卡片列表 | +| GET | `/admin/cards/new` | CardCreateGet | AuthRequired | 新增卡片表单 | +| POST | `/admin/cards` | CardCreatePost | AuthRequired | 新增卡片提交 | +| GET | `/admin/cards/:id/edit` | CardEditGet | AuthRequired | 编辑卡片表单 | +| POST | `/admin/cards/:id` | CardEditPost | AuthRequired | 编辑卡片提交 | +| POST | `/admin/cards/:id/delete` | CardDelete | AuthRequired | 删除卡片 | +| POST | `/admin/cards/:id/toggle` | CardToggle | AuthRequired | 启用/禁用卡片 | +| POST | `/admin/cards/:id/move-up` | CardMoveUp | AuthRequired | 卡片上移 | +| POST | `/admin/cards/:id/move-down` | CardMoveDown | AuthRequired | 卡片下移 | +| GET | `/admin/settings` | SettingsGet | AuthRequired | 搜索引擎设置页 | +| POST | `/admin/settings` | SettingsPost | AuthRequired | 保存搜索引擎设置 | + +--- + +### 4. 程序调用流程 + +#### 4.1 主页加载 + +```mermaid +sequenceDiagram + participant U as 用户浏览器 + participant G as Gin Router + participant H as HomeHandler + participant CM as CardModel + participant SM as SettingModel + participant T as html/template + + U->>G: GET / + G->>H: HomeHandler(c) + H->>CM: GetEnabledCards() + CM->>CM: SELECT * FROM cards WHERE enabled=1 ORDER BY sort + CM-->>H: []Card + H->>SM: GetSetting("search_engine") + SM->>SM: SELECT value FROM settings WHERE key='search_engine' + SM-->>H: search URL template + H->>T: ExecuteTemplate("home.html", data) + T-->>U: HTML (搜索框 + 卡片网格) +``` + +#### 4.2 搜索跳转 + +```mermaid +sequenceDiagram + participant U as 用户浏览器 + participant JS as 前端 JavaScript + participant SE as 搜索引擎 + + U->>JS: 输入关键词,按回车/点击搜索 + JS->>JS: 读取隐藏域 search_url_template + JS->>JS: fmt.Sprintf(template, query) + JS->>U: window.location.href = 拼接后的URL + U->>SE: HTTP 请求到搜索引擎 +``` + +> 注意:搜索跳转纯前端 JS 完成,不经过后端路由。 + +#### 4.3 后台登录 + +```mermaid +sequenceDiagram + participant U as 用户浏览器 + participant G as Gin Router + participant H as HandlerAdmin + participant AM as AdminModel + participant SS as SessionStore + + U->>G: GET /admin/login + G->>H: LoginGet(c) + H-->>U: 登录页 HTML + + U->>G: POST /admin/login {username, password} + G->>H: LoginPost(c) + H->>AM: VerifyPassword(username, password) + AM->>AM: bcrypt.CompareHashAndPassword() + AM-->>H: true/false + + alt 验证成功 + H->>SS: Create(adminID, username) + SS-->>H: sessionID + H->>H: c.SetCookie("session_id", sessionID, 86400, "/", "", false, true) + H-->>U: 302 Redirect → /admin + else 验证失败 + H-->>U: 200 + 登录页(错误提示) + end +``` + +#### 4.4 卡片增删改查 + +```mermaid +sequenceDiagram + participant U as 管理员浏览器 + participant G as Gin Router + participant MW as AuthMiddleware + participant H as HandlerCards + participant CM as CardModel + + Note over U,CM: 卡片列表 + U->>G: GET /admin/cards + G->>MW: AuthRequired() + MW->>MW: 检查 Cookie session_id + alt 未登录 + MW-->>U: 302 → /admin/login + else 已登录 + MW->>H: CardsList(c) + H->>CM: GetAllCards() + CM-->>H: []Card + H-->>U: 卡片列表 HTML + end + + Note over U,CM: 新增卡片 + U->>G: GET /admin/cards/new + G->>H: CardCreateGet(c) + H-->>U: 空表单 HTML + + U->>G: POST /admin/cards {icon, title, subtitle, url} + G->>H: CardCreatePost(c) + H->>CM: CreateCard(&Card{...}) + CM->>CM: INSERT INTO cards(...) + CM-->>H: nil + H-->>U: 302 Redirect → /admin/cards + + Note over U,CM: 编辑卡片 + U->>G: GET /admin/cards/:id/edit + G->>H: CardEditGet(c) + H->>CM: GetCardByID(id) + CM-->>H: *Card + H-->>U: 预填表单 HTML + + U->>G: POST /admin/cards/:id {icon, title, subtitle, url} + G->>H: CardEditPost(c) + H->>CM: UpdateCard(&Card{...}) + CM->>CM: UPDATE cards SET ... WHERE id=? + CM-->>H: nil + H-->>U: 302 Redirect → /admin/cards + + Note over U,CM: 删除卡片 + U->>G: POST /admin/cards/:id/delete + G->>H: CardDelete(c) + H->>CM: DeleteCard(id) + CM->>CM: DELETE FROM cards WHERE id=? + CM-->>H: nil + H-->>U: 302 Redirect → /admin/cards +``` + +#### 4.5 卡片排序(上移/下移) + +```mermaid +sequenceDiagram + participant U as 管理员浏览器 + participant G as Gin Router + participant H as HandlerCards + participant CM as CardModel + + U->>G: POST /admin/cards/:id/move-up + G->>H: CardMoveUp(c) + H->>CM: MoveCardUp(id) + CM->>CM: 获取当前卡片 sort 值 + CM->>CM: 获取 sort 值仅次于当前卡片的上一张卡片 + CM->>CM: 交换两者 sort 值 (UPDATE) + CM-->>H: nil + H-->>U: 302 Redirect → /admin/cards + + Note over U,CM: 下移同理,方向相反 +``` + +#### 4.6 卡片启用/禁用 + +```mermaid +sequenceDiagram + participant U as 管理员浏览器 + participant G as Gin Router + participant H as HandlerCards + participant CM as CardModel + + U->>G: POST /admin/cards/:id/toggle + G->>H: CardToggle(c) + H->>CM: ToggleCard(id) + CM->>CM: UPDATE cards SET enabled = CASE WHEN enabled=1 THEN 0 ELSE 1 END WHERE id=? + CM-->>H: nil + H-->>U: 302 Redirect → /admin/cards +``` + +#### 4.7 搜索引擎配置 + +```mermaid +sequenceDiagram + participant U as 管理员浏览器 + participant G as Gin Router + participant H as HandlerSettings + participant SM as SettingModel + + U->>G: GET /admin/settings + G->>H: SettingsGet(c) + H->>SM: GetSetting("search_engine") + SM-->>H: 当前搜索引擎 URL 模板 + H-->>U: 设置页 HTML(预设选项 + 自定义输入框) + + U->>G: POST /admin/settings {engine: "google" | "bing" | "baidu" | "custom", custom_url: "..."} + G->>H: SettingsPost(c) + H->>H: 根据选项确定 URL 模板 + alt 预设引擎 + H->>H: 映射到固定 URL 模板 + else 自定义 + H->>H: 使用 custom_url,验证含 %s + end + H->>SM: SetSetting("search_engine", urlTemplate) + SM->>SM: INSERT OR REPLACE INTO settings(key, value) VALUES(...) + SM-->>H: nil + H-->>U: 302 Redirect → /admin/settings +``` + +--- + +### 5. 不确定事项与假设 + +| 编号 | 问题 | 假设/默认决策 | +|------|------|--------------| +| UNC-1 | 管理员是否支持多账号? | PRD 已定:初始 admin/admin123 硬编码,暂不考虑多账号管理 UI,但数据模型支持 | +| UNC-2 | 搜索引擎配置是否需要多个? | 当前仅支持配置一个默认搜索引擎,settings 表 key=`search_engine` | +| UNC-3 | 卡片图标是否需要文件上传? | PRD 已定:仅支持 emoji 文字和 favicon URL,通过文本输入框填写 | +| UNC-4 | CSRF 防护 | 简易项目暂不实现 CSRF Token(所有写操作需登录 + Cookie HttpOnly) | +| UNC-5 | HTTPS | 不在应用层处理,由部署环境(反向代理)负责 | +| UNC-6 | 端口配置 | 默认监听 `:8080`,可通过环境变量 `PORT` 覆盖 | + +--- + +## Part B: 任务分解 + +--- + +### 6. 依赖包列表 + +``` +github.com/gin-gonic/gin@v1.10.0 # HTTP 框架 +modernc.org/sqlite@latest # 纯 Go SQLite 驱动 +golang.org/x/crypto@latest # bcrypt 密码哈希 +``` + +> 仅 3 个直接依赖,保持极简。 + +--- + +### 7. 任务列表 + +#### T01: 项目基础设施 + +**说明**:创建项目骨架,包括模块初始化、数据库初始化、会话管理、全局布局模板、静态文件服务和程序入口。 + +**源文件**: +- `go.mod`, `go.sum` +- `main.go` +- `database/db.go` +- `session/session.go` +- `middleware/auth.go` +- `templates/layout.html` +- `static/style.css`(基础框架样式:布局、导航、表单通用样式) + +**具体内容**: +1. `go mod init` + 添加依赖 +2. `database/db.go`:`InitDB()` — 打开 SQLite 文件、建表(cards/settings/admins)、插入默认管理员(admin/admin123 bcrypt hash)、插入默认搜索引擎(Google) +3. `session/session.go`:`SessionStore` 结构体 + `Create/Get/Delete` 方法 +4. `middleware/auth.go`:`AuthRequired()` 中间件,从 Cookie 读取 session_id,校验失败 302 到 `/admin/login` +5. `templates/layout.html`:HTML 骨架(`{{define "layout"}}` 含 head/nav/footer,`{{template "content" .}}` 插槽) +6. `static/style.css`:CSS 变量定义、reset、布局容器、导航栏、表单基础样式 +7. `main.go`:初始化 DB、创建 SessionStore、注册所有路由(此阶段 handler 只返回空字符串占位)、启动服务 + +**依赖**:无 + +**优先级**:P0 + +--- + +#### T02: 数据模型层 + +**说明**:实现所有数据模型的 CRUD 操作,与 SQLite 交互。 + +**源文件**: +- `models/card.go` +- `models/setting.go` +- `models/admin.go` + +**具体内容**: +1. `models/card.go`: + - `Card` struct(映射 cards 表) + - `GetAllCards()` — 查询全部卡片(后台用),按 sort ASC + - `GetEnabledCards()` — 仅查询启用的卡片(主页用),按 sort ASC + - `GetCardByID(id int)` — 单卡片查询 + - `CreateCard(card *Card)` — INSERT,sort 自动取 MAX(sort)+1 + - `UpdateCard(card *Card)` — UPDATE by ID + - `DeleteCard(id int)` — DELETE by ID + - `ToggleCard(id int)` — 切换 enabled 字段 + - `MoveCardUp(id int)` — 交换 sort 值上移 + - `MoveCardDown(id int)` — 交换 sort 值下移 +2. `models/setting.go`: + - `Setting` struct + - 预定义搜索引擎常量:`SearchEngineGoogle = "https://www.google.com/search?q=%s"` 等 + - `GetSetting(key string) (string, error)` + - `SetSetting(key, value string) error` +3. `models/admin.go`: + - `Admin` struct + - `GetAdminByUsername(username string) (*Admin, error)` + - `CreateAdmin(username, hashedPassword string) error` + - `VerifyPassword(username, password string) (bool, *Admin, error)` — bcrypt 校验 + - `ChangePassword(adminID int, newHashedPassword string) error` + +**依赖**:T01(需要 `database/db.go` 中初始化的 `*sql.DB`) + +**优先级**:P0 + +--- + +#### T03: 后台管理 — 登录 + 卡片管理 + +**说明**:实现后台核心功能:管理员登录/登出、卡片 CRUD、启用/禁用、排序。 + +**源文件**: +- `handlers/admin.go` +- `handlers/cards.go` +- `templates/admin/login.html` +- `templates/admin/index.html` +- `templates/admin/cards.html` +- `templates/admin/card_form.html` + +**具体内容**: +1. `handlers/admin.go`: + - `LoginGet` — 渲染登录页 + - `LoginPost` — 表单验证 → `AdminModel.VerifyPassword()` → 创建 Session → 写 Cookie → 302 到 `/admin` + - `Logout` — 删除 Session → 清 Cookie → 302 到 `/admin/login` + - `AdminIndex` — 渲染后台首页 +2. `handlers/cards.go`: + - `CardsList` — 调用 `GetAllCards()`,渲染列表页 + - `CardCreateGet` / `CardCreatePost` — 新增卡片 + - `CardEditGet` / `CardEditPost` — 编辑卡片 + - `CardDelete` — 删除卡片 + - `CardToggle` — 切换启用/禁用 + - `CardMoveUp` / `CardMoveDown` — 排序 +3. 模板文件:登录页、后台首页、卡片列表(含操作按钮)、卡片表单(新增/编辑共用) + +**依赖**:T01(路由和中间件)、T02(数据模型) + +**优先级**:P0 + +--- + +#### T04: 主页 + 搜索引擎配置 + +**说明**:实现面向用户的主页(搜索框 + 导航卡片)和后台搜索引擎配置。 + +**源文件**: +- `handlers/home.go` +- `handlers/settings.go` +- `templates/home.html` +- `templates/admin/settings.html` + +**具体内容**: +1. `handlers/home.go`: + - `HomeHandler` — 获取启用卡片 + 当前搜索引擎 URL 模板,渲染主页 + - 搜索框前端 JS:读取隐藏域中的 URL 模板,拼接 `%s` 占位符,`window.location.href` 跳转 + - 搜索框自动聚焦(`autofocus` 属性) +2. `handlers/settings.go`: + - `SettingsGet` — 读取当前配置,渲染设置页 + - `SettingsPost` — 接收引擎选择(预设/自定义),自定义 URL 需包含 `%s`,保存到 settings 表 +3. `templates/home.html`:搜索框(大尺寸居中)+ 卡片网格(图标/标题/副标题,可点击跳转) +4. `templates/admin/settings.html`:预设引擎单选 + 自定义 URL 输入 + +**依赖**:T01、T02 + +**优先级**:P0 + +--- + +#### T05: 样式完善 + 集成调试 + +**说明**:完善所有页面 CSS 样式,端到端集成测试,修复问题。 + +**源文件**: +- `static/style.css`(补充完善所有页面样式) +- `templates/layout.html`(可能微调) +- `main.go`(最终路由确认) + +**具体内容**: +1. 完善主页样式:卡片网格(CSS Grid/Flexbox)、hover 效果、搜索框美观 +2. 完善后台样式:表单对齐、按钮样式、卡片操作按钮布局 +3. 响应式基础:卡片网格自适应列数 +4. 端到端手动验证: + - 首次启动 → 默认管理员 → 登录成功 + - 新增/编辑/删除/排序卡片 → 主页正确展示 + - 切换搜索引擎 → 主页搜索跳转正确 + - 未登录访问后台 → 重定向到登录页 +5. 修复发现的问题 + +**依赖**:T03、T04 + +**优先级**:P1 + +--- + +### 8. 跨文件共享约定 + +``` +- 全局 *sql.DB 实例:通过 database 包导出的 DB 变量访问,models 包直接引用 database.DB +- Session Cookie 名称:"session_id",HttpOnly=true,SameSite=Lax,MaxAge=86400(24小时) +- Session Store 实例:main.go 中创建,通过闭包注入到 middleware 和 handlers +- 模板数据传递:所有 handler 通过 gin.H 传递,模板中统一使用 .Cards / .Card / .SearchEngine / .Error / .Message +- 模板继承:所有页面通过 {{template "layout" .}} 包裹,子页面 {{define "content"}}...{{end}} +- 错误处理:handler 层 c.HTML(500, ...) 或 302 重定向,不向用户暴露内部错误 +- 表单提交:所有写操作使用 POST + 302 重定向(PRG 模式),防止重复提交 +- URL 模板占位符:%s(Go fmt.Sprintf 兼容) +- 静态文件路径:/static/ → ./static/(gin.Static) +- SQLite 文件路径:./data/portal.db(自动创建 data 目录) +- 卡片 sort 值:数字越小越靠前,新建卡片 sort=MAX(sort)+1 +- 搜索引擎配置 key:"search_engine",value 为完整 URL 模板 +- 管理员默认账号:admin / admin123(bcrypt hash 存储于 DB) +- 所有日期字段使用 SQLite CURRENT_TIMESTAMP(RFC 3339 格式) +- 后台路由统一前缀:/admin/ +- 模板文件路径:相对于项目根目录的 templates/ 目录 +- CSS 类命名:BEM 风格简化版,如 .card-grid / .card-item / .card-item__icon / .search-box +``` + +--- + +### 9. 任务依赖图 + +```mermaid +graph TD + T01[T01: 项目基础设施
go.mod, main.go, db, session,
middleware, layout, style.css] + T02[T02: 数据模型层
models/card.go, setting.go, admin.go] + T03[T03: 后台管理
handlers/admin.go, cards.go,
templates/admin/*] + T04[T04: 主页 + 搜索引擎配置
handlers/home.go, settings.go,
templates/home.html, admin/settings.html] + T05[T05: 样式完善 + 集成调试
style.css 补充, 全流程验证] + + T01 --> T02 + T01 --> T03 + T01 --> T04 + T02 --> T03 + T02 --> T04 + T03 --> T05 + T04 --> T05 +``` + +> T03 和 T04 可并行开发(均依赖 T01 + T02),最终汇聚到 T05 集成。 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5f1cdaa --- /dev/null +++ b/go.mod @@ -0,0 +1,46 @@ +module simple_portal + +go 1.22 + +require ( + github.com/disintegration/imaging v1.6.2 + github.com/gin-gonic/gin v1.10.0 + github.com/google/uuid v1.6.0 + golang.org/x/crypto v0.28.0 + modernc.org/sqlite v1.34.5 +) + +require ( + github.com/bytedance/sonic v1.11.6 // indirect + github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.20.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + golang.org/x/arch v0.8.0 // indirect + golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect + golang.org/x/net v0.25.0 // indirect + golang.org/x/sys v0.26.0 // indirect + golang.org/x/text v0.19.0 // indirect + google.golang.org/protobuf v1.34.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.55.7 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.8.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7ef8a5b --- /dev/null +++ b/go.sum @@ -0,0 +1,134 @@ +github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= +github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= +github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= +golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= +golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8= +golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= +golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg= +golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= +modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= +modernc.org/ccgo/v4 v4.20.5 h1:s04akhT2dysD0DFOlv9fkQ6oUTLPYgMnnDk9oaqjszM= +modernc.org/ccgo/v4 v4.20.5/go.mod h1:fYXClPUMWxWaz1Xj5sHbzW/ZENEFeuHLToqBxUk41nE= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.4.3 h1:Ik4ZcMbC7aY4ZDPUhzXVXi7GMub9QcXLTfXn3mWpNw8= +modernc.org/gc/v2 v2.4.3/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= +modernc.org/libc v1.55.7 h1:/5PMGAF3tyZhK72WpoqeLNtgUUpYMrnhT+Gm/5tVDgs= +modernc.org/libc v1.55.7/go.mod h1:JXguUpMkbw1gknxspNE9XaG+kk9hDAAnBxpA6KGLiyA= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= +modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= +modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g= +modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/handlers/admin.go b/handlers/admin.go new file mode 100644 index 0000000..5982abc --- /dev/null +++ b/handlers/admin.go @@ -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. diff --git a/handlers/analytics.go b/handlers/analytics.go new file mode 100644 index 0000000..1b89ae5 --- /dev/null +++ b/handlers/analytics.go @@ -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, + }) +} diff --git a/handlers/cards.go b/handlers/cards.go new file mode 100644 index 0000000..821160c --- /dev/null +++ b/handlers/cards.go @@ -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") +} diff --git a/handlers/home.go b/handlers/home.go new file mode 100644 index 0000000..2465506 --- /dev/null +++ b/handlers/home.go @@ -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, + }) +} diff --git a/handlers/security.go b/handlers/security.go new file mode 100644 index 0000000..13809c7 --- /dev/null +++ b/handlers/security.go @@ -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") +} diff --git a/handlers/settings.go b/handlers/settings.go new file mode 100644 index 0000000..1067c0a --- /dev/null +++ b/handlers/settings.go @@ -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") +} diff --git a/handlers/upload.go b/handlers/upload.go new file mode 100644 index 0000000..80ca67e --- /dev/null +++ b/handlers/upload.go @@ -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) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..c792d94 --- /dev/null +++ b/main.go @@ -0,0 +1,161 @@ +package main + +import ( + "html/template" + "log" + "os" + "path/filepath" + "strings" + + "simple_portal/database" + "simple_portal/handlers" + "simple_portal/middleware" + "simple_portal/session" + + "github.com/gin-gonic/gin" +) + +// loadTemplates loads HTML templates from templates/ directory recursively. +// Custom implementation because Go's ParseGlob has issues with directories on Windows. +func loadTemplates() *template.Template { + funcMap := template.FuncMap{ + "hasPrefix": strings.HasPrefix, + "sub": func(a, b int) int { return a - b }, + "add": func(a, b int) int { return a + b }, + } + t := template.New("").Funcs(funcMap) + // 收集所有 .html 模板文件路径 + var files []string + filepath.Walk("templates", func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if !info.IsDir() && filepath.Ext(path) == ".html" { + files = append(files, path) + } + return nil + }) + if len(files) == 0 { + log.Fatal("No template files found in templates/") + } + // 将 Windows 反斜杠路径转为正斜杠,避免模板名问题 + for i, f := range files { + files[i] = filepath.ToSlash(f) + } + var terr error + t, terr = t.ParseFiles(files...) + if terr != nil { + log.Fatalf("Failed to parse templates: %v", terr) + } + return t +} + +func main() { + // Initialize database + if err := database.InitDB(); err != nil { + log.Fatalf("Failed to initialize database: %v", err) + } + defer database.CloseDB() + + // Create uploads directory + if err := os.MkdirAll(filepath.Join(".", "data", "uploads"), 0755); err != nil { + log.Fatalf("Failed to create uploads directory: %v", err) + } + + // Create session store + sessionStore := session.NewSessionStore() + + // Create IP ban guard (in-memory fail counter) + ipBanGuard := middleware.NewIPBanGuard() + + // Set Gin mode + ginMode := os.Getenv("GIN_MODE") + if ginMode == "" { + gin.SetMode(gin.DebugMode) + } + + r := gin.Default() + + // Load HTML templates (custom loader for nested directories) + r.SetHTMLTemplate(loadTemplates()) + + // Serve static files + r.Static("/static", "./static") + + // Inject session store and IP ban guard into context for handlers + r.Use(func(c *gin.Context) { + c.Set("sessionStore", sessionStore) + c.Set("ipBanGuard", ipBanGuard) + c.Next() + }) + + // Public routes (home page and uploads — no IP restriction) + r.GET("/", handlers.HomeHandler) + r.GET("/click/:id", handlers.CardClickHandler) + r.GET("/search", handlers.SearchHandler) + r.GET("/uploads/:filename", handlers.ServeUploadHandler) + + // Admin routes with IP whitelist check applied to all /admin/* routes + adminGroup := r.Group("/admin") + adminGroup.Use(middleware.IPWhitelistRequired(func(sessionID string) bool { + return sessionStore.Get(sessionID) != nil + })) + { + // Public admin routes (login — no auth required, but IP whitelist applies) + adminGroup.GET("/login", handlers.LoginGet) + adminGroup.POST("/login", handlers.LoginPost) + + // Protected admin routes (auth required) + protected := adminGroup.Group("") + protected.Use(middleware.AuthRequired(sessionStore)) + { + protected.POST("/logout", handlers.Logout) + protected.GET("/", handlers.AdminIndex) + + // Cards management + protected.GET("/cards", handlers.CardsList) + protected.GET("/cards/new", handlers.CardCreateGet) + protected.POST("/cards", handlers.CardCreatePost) + protected.GET("/cards/:id/edit", handlers.CardEditGet) + protected.POST("/cards/:id", handlers.CardEditPost) + protected.POST("/cards/:id/delete", handlers.CardDelete) + protected.POST("/cards/:id/toggle", handlers.CardToggle) + protected.POST("/cards/:id/move-up", handlers.CardMoveUp) + protected.POST("/cards/:id/move-down", handlers.CardMoveDown) + + // Image upload + protected.POST("/upload", handlers.UploadHandler) + + // Settings + protected.GET("/settings", handlers.SettingsGet) + protected.POST("/settings", handlers.SettingsPost) + + // Security: login logs + protected.GET("/logs", handlers.LoginLogsGet) + protected.POST("/logs/unban/:id", handlers.UnbanIP) + + // Security: change password + protected.GET("/password", handlers.ChangePasswordGet) + protected.POST("/password", handlers.ChangePasswordPost) + + // Security: IP whitelist management + protected.GET("/ip-whitelist", handlers.IPWhitelistGet) + protected.POST("/ip-whitelist/add", handlers.IPWhitelistAdd) + protected.POST("/ip-whitelist/:id/delete", handlers.IPWhitelistDelete) + + // Analytics: access logs + protected.GET("/access-logs", handlers.AccessLogsGet) + } + } + + // Determine port + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + log.Printf("Starting Portal server on :%s", port) + if err := r.Run(":" + port); err != nil { + log.Fatalf("Failed to start server: %v", err) + } +} diff --git a/middleware/auth.go b/middleware/auth.go new file mode 100644 index 0000000..34aa629 --- /dev/null +++ b/middleware/auth.go @@ -0,0 +1,35 @@ +package middleware + +import ( + "net/http" + + "simple_portal/session" + + "github.com/gin-gonic/gin" +) + +// AuthRequired returns a Gin middleware that checks for a valid session. +// If the session is invalid or missing, redirects to /admin/login. +func AuthRequired(store *session.SessionStore) gin.HandlerFunc { + return func(c *gin.Context) { + sessionID, err := c.Cookie("session_id") + if err != nil || sessionID == "" { + c.Redirect(http.StatusFound, "/admin/login") + c.Abort() + return + } + + data := store.Get(sessionID) + if data == nil { + c.Redirect(http.StatusFound, "/admin/login") + c.Abort() + return + } + + // Store session data in context for handlers to use + c.Set("adminID", data.AdminID) + c.Set("username", data.Username) + c.Set("sessionID", sessionID) + c.Next() + } +} diff --git a/middleware/ipguard.go b/middleware/ipguard.go new file mode 100644 index 0000000..7211738 --- /dev/null +++ b/middleware/ipguard.go @@ -0,0 +1,116 @@ +package middleware + +import ( + "net/http" + "sync" + "time" + + "simple_portal/models" + + "github.com/gin-gonic/gin" +) + +// FailRecord stores the login failure count and first failure time for an IP. +type FailRecord struct { + Count int + FirstAt time.Time +} + +// IPBanGuard maintains an in-memory counter of login failures per IP. +// When 5 failures occur within 5 minutes, the IP is automatically banned. +type IPBanGuard struct { + failMap sync.Map // map[string]*FailRecord +} + +// NewIPBanGuard creates a new IPBanGuard instance. +func NewIPBanGuard() *IPBanGuard { + return &IPBanGuard{} +} + +// RecordFail records a login failure for the given IP. +// It returns true if the IP should be auto-banned (5 failures within 5 minutes). +// The caller is responsible for creating the actual ban record in the database. +func (g *IPBanGuard) RecordFail(ip string) bool { + now := time.Now() + + // Try to load existing record + if val, ok := g.failMap.Load(ip); ok { + rec := val.(*FailRecord) + // Check if within 5-minute window + if now.Sub(rec.FirstAt) > 5*time.Minute { + // Reset — outside the window + rec.Count = 1 + rec.FirstAt = now + return false + } + rec.Count++ + if rec.Count >= 5 { + // Auto-ban: reset the counter after creating the ban + g.failMap.Delete(ip) + return true + } + return false + } + + // New record + g.failMap.Store(ip, &FailRecord{Count: 1, FirstAt: now}) + return false +} + +// ResetFail resets the failure counter for the given IP (on successful login). +func (g *IPBanGuard) ResetFail(ip string) { + g.failMap.Delete(ip) +} + +// IPWhitelistRequired returns a Gin middleware that checks if the visitor's IP +// is in the whitelist. Only when the whitelist table has records does it restrict +// access; when the whitelist is empty, no IP is restricted. +// +// 登录页面路由(/admin/login)始终放行。 +// 已通过 session 认证的用户也放行,避免管理员添加白名单后自己被锁定。 +// sessionCheck 是一个函数,接收 sessionID 返回是否有效。 +func IPWhitelistRequired(sessionCheck func(sessionID string) bool) gin.HandlerFunc { + return func(c *gin.Context) { + // 登录页面始终放行 + path := c.Request.URL.Path + if path == "/admin/login" { + c.Next() + return + } + + hasWhitelist, err := models.HasWhitelist() + if err != nil { + c.Next() + return + } + if !hasWhitelist { + c.Next() + return + } + + clientIP := c.ClientIP() + allowed, err := models.IsIPWhitelisted(clientIP) + if err != nil { + c.Next() + return + } + if allowed { + c.Next() + return + } + + // IP不在白名单中,但如果有有效session则放行 + if sessionID, err := c.Cookie("session_id"); err == nil && sessionID != "" { + if sessionCheck(sessionID) { + c.Next() + return + } + } + + c.HTML(http.StatusForbidden, "admin/403.html", gin.H{ + "Title": "访问被拒绝", + "IP": clientIP, + }) + c.Abort() + } +} diff --git a/models/accesslog.go b/models/accesslog.go new file mode 100644 index 0000000..dc7d905 --- /dev/null +++ b/models/accesslog.go @@ -0,0 +1,193 @@ +package models + +import ( + "fmt" + "time" + + "simple_portal/database" +) + +// 访问动作类型常量 +const ( + ActionTypeVisit = "visit" // 主页访问 + ActionTypeClick = "click" // 卡片点击 + ActionTypeSearch = "search" // 搜索 +) + +// AccessLog 表示一条访问日志记录。 +type AccessLog struct { + ID int `json:"id"` + IP string `json:"ip"` + UserAgent string `json:"user_agent"` + ActionType string `json:"action_type"` // visit / click / search + Detail string `json:"detail"` // 卡片标题 / 搜索关键词 + Referer string `json:"referer"` + CreatedAt time.Time `json:"created_at"` +} + +// CreateAccessLog 插入一条访问日志。 +func CreateAccessLog(ip, userAgent, actionType, detail, referer string) error { + _, err := database.DB.Exec( + "INSERT INTO access_logs (ip, user_agent, action_type, detail, referer, created_at) VALUES (?, ?, ?, ?, ?, ?)", + ip, userAgent, actionType, detail, referer, time.Now().Format("2006-01-02 15:04:05"), + ) + if err != nil { + return fmt.Errorf("failed to create access log: %w", err) + } + return nil +} + +// AccessLogStats 访问统计数据 +type AccessLogStats struct { + TodayViews int `json:"today_views"` // 今日浏览次数 + TotalViews int `json:"total_views"` // 总浏览次数 + NewIPs int `json:"new_ips"` // 今日新IP数量 + TotalIPs int `json:"total_ips"` // 总IP数量 +} + +// GetAccessLogStats 获取访问统计数据 +func GetAccessLogStats() (*AccessLogStats, error) { + stats := &AccessLogStats{} + + // 今日浏览次数 + err := database.DB.QueryRow( + "SELECT COUNT(*) FROM access_logs WHERE date(created_at) = date('now', 'localtime')", + ).Scan(&stats.TodayViews) + if err != nil { + return nil, fmt.Errorf("failed to count today views: %w", err) + } + + // 总浏览次数 + err = database.DB.QueryRow("SELECT COUNT(*) FROM access_logs").Scan(&stats.TotalViews) + if err != nil { + return nil, fmt.Errorf("failed to count total views: %w", err) + } + + // 总IP数量 + err = database.DB.QueryRow("SELECT COUNT(DISTINCT ip) FROM access_logs").Scan(&stats.TotalIPs) + if err != nil { + return nil, fmt.Errorf("failed to count total IPs: %w", err) + } + + // 今日新IP数量(今日访问但之前从未出现过的IP) + err = database.DB.QueryRow(` + SELECT COUNT(DISTINCT a1.ip) + FROM access_logs a1 + WHERE date(a1.created_at) = date('now', 'localtime') + AND NOT EXISTS ( + SELECT 1 FROM access_logs a2 + WHERE a2.ip = a1.ip AND date(a2.created_at) < date('now', 'localtime') + ) + `).Scan(&stats.NewIPs) + if err != nil { + return nil, fmt.Errorf("failed to count new IPs: %w", err) + } + + return stats, nil +} + +// GetRecentAccessLogs 获取最近的访问日志(用于实时流量展示) +func GetRecentAccessLogs(limit int) ([]AccessLog, error) { + rows, err := database.DB.Query( + "SELECT id, ip, user_agent, action_type, detail, referer, created_at FROM access_logs ORDER BY created_at DESC LIMIT ?", + limit, + ) + if err != nil { + return nil, fmt.Errorf("failed to query recent access logs: %w", err) + } + defer rows.Close() + + var logs []AccessLog + for rows.Next() { + var l AccessLog + if err := rows.Scan(&l.ID, &l.IP, &l.UserAgent, &l.ActionType, &l.Detail, &l.Referer, &l.CreatedAt); err != nil { + return nil, fmt.Errorf("failed to scan access log: %w", err) + } + logs = append(logs, l) + } + return logs, rows.Err() +} + +// GetAccessLogs 分页获取访问日志,支持按IP和动作类型筛选。 +func GetAccessLogs(page, pageSize int, filterIP, filterAction string) ([]AccessLog, int, error) { + // 构建查询条件 + where := "WHERE 1=1" + args := []interface{}{} + + if filterIP != "" { + where += " AND ip LIKE ?" + args = append(args, "%"+filterIP+"%") + } + if filterAction != "" { + where += " AND action_type = ?" + args = append(args, filterAction) + } + + // 获取总数 + var total int + countArgs := make([]interface{}, len(args)) + copy(countArgs, args) + err := database.DB.QueryRow("SELECT COUNT(*) FROM access_logs "+where, countArgs...).Scan(&total) + if err != nil { + return nil, 0, fmt.Errorf("failed to count access logs: %w", err) + } + + // 分页查询 + offset := (page - 1) * pageSize + if offset < 0 { + offset = 0 + } + queryArgs := append(args, pageSize, offset) + rows, err := database.DB.Query( + "SELECT id, ip, user_agent, action_type, detail, referer, created_at FROM access_logs "+where+" ORDER BY created_at DESC LIMIT ? OFFSET ?", + queryArgs..., + ) + if err != nil { + return nil, 0, fmt.Errorf("failed to query access logs: %w", err) + } + defer rows.Close() + + var logs []AccessLog + for rows.Next() { + var l AccessLog + if err := rows.Scan(&l.ID, &l.IP, &l.UserAgent, &l.ActionType, &l.Detail, &l.Referer, &l.CreatedAt); err != nil { + return nil, 0, fmt.Errorf("failed to scan access log: %w", err) + } + logs = append(logs, l) + } + return logs, total, rows.Err() +} + +// GetAccessLogStatsByIP 获取按IP统计的访问数据 +func GetAccessLogStatsByIP(limit int) ([]struct { + IP string `json:"ip"` + Visits int `json:"visits"` + LastSeen string `json:"last_seen"` +}, error) { + rows, err := database.DB.Query( + "SELECT ip, COUNT(*) as visits, MAX(created_at) as last_seen FROM access_logs GROUP BY ip ORDER BY visits DESC LIMIT ?", + limit, + ) + if err != nil { + return nil, fmt.Errorf("failed to query IP stats: %w", err) + } + defer rows.Close() + + var result []struct { + IP string `json:"ip"` + Visits int `json:"visits"` + LastSeen string `json:"last_seen"` + } + for rows.Next() { + var r struct { + IP string `json:"ip"` + Visits int `json:"visits"` + LastSeen string `json:"last_seen"` + } + if err := rows.Scan(&r.IP, &r.Visits, &r.LastSeen); err != nil { + return nil, fmt.Errorf("failed to scan IP stats: %w", err) + } + result = append(result, r) + } + return result, rows.Err() +} diff --git a/models/admin.go b/models/admin.go new file mode 100644 index 0000000..90f71a9 --- /dev/null +++ b/models/admin.go @@ -0,0 +1,75 @@ +package models + +import ( + "database/sql" + "fmt" + + "simple_portal/database" + + "golang.org/x/crypto/bcrypt" +) + +// Admin represents an administrator account. +type Admin struct { + ID int `json:"id"` + Username string `json:"username"` + Password string `json:"-"` // Never expose password hash in JSON +} + +// GetAdminByUsername returns an admin by username. Returns nil if not found. +func GetAdminByUsername(username string) (*Admin, error) { + var a Admin + err := database.DB.QueryRow( + "SELECT id, username, password FROM admins WHERE username = ?", + username, + ).Scan(&a.ID, &a.Username, &a.Password) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("failed to get admin by username: %w", err) + } + return &a, nil +} + +// CreateAdmin inserts a new admin with a bcrypt-hashed password. +func CreateAdmin(username, hashedPassword string) error { + _, err := database.DB.Exec( + "INSERT INTO admins (username, password) VALUES (?, ?)", + username, hashedPassword, + ) + if err != nil { + return fmt.Errorf("failed to create admin: %w", err) + } + return nil +} + +// VerifyPassword checks if the given plain-text password matches the stored bcrypt hash. +// Returns (matched, admin, error). +func VerifyPassword(username, password string) (bool, *Admin, error) { + admin, err := GetAdminByUsername(username) + if err != nil { + return false, nil, err + } + if admin == nil { + return false, nil, nil + } + + err = bcrypt.CompareHashAndPassword([]byte(admin.Password), []byte(password)) + if err != nil { + return false, nil, nil + } + return true, admin, nil +} + +// ChangePassword updates the password hash for an admin by ID. +func ChangePassword(adminID int, newHashedPassword string) error { + _, err := database.DB.Exec( + "UPDATE admins SET password = ? WHERE id = ?", + newHashedPassword, adminID, + ) + if err != nil { + return fmt.Errorf("failed to change password: %w", err) + } + return nil +} diff --git a/models/card.go b/models/card.go new file mode 100644 index 0000000..adc954d --- /dev/null +++ b/models/card.go @@ -0,0 +1,224 @@ +package models + +import ( + "database/sql" + "fmt" + + "simple_portal/database" +) + +// Card represents a navigation card on the portal home page. +type Card struct { + ID int `json:"id"` + Icon string `json:"icon"` + Title string `json:"title"` + Subtitle string `json:"subtitle"` + URL string `json:"url"` + Sort int `json:"sort"` + Enabled bool `json:"enabled"` + CreatedAt string `json:"created_at"` +} + +// GetAllCards returns all cards ordered by sort ascending. +func GetAllCards() ([]Card, error) { + rows, err := database.DB.Query("SELECT id, icon, title, subtitle, url, sort, enabled, created_at FROM cards ORDER BY sort ASC") + if err != nil { + return nil, fmt.Errorf("failed to query cards: %w", err) + } + defer rows.Close() + + var cards []Card + for rows.Next() { + var c Card + var enabled int + if err := rows.Scan(&c.ID, &c.Icon, &c.Title, &c.Subtitle, &c.URL, &c.Sort, &enabled, &c.CreatedAt); err != nil { + return nil, fmt.Errorf("failed to scan card: %w", err) + } + c.Enabled = enabled == 1 + cards = append(cards, c) + } + return cards, rows.Err() +} + +// GetEnabledCards returns only enabled cards ordered by sort ascending. +func GetEnabledCards() ([]Card, error) { + rows, err := database.DB.Query("SELECT id, icon, title, subtitle, url, sort, enabled, created_at FROM cards WHERE enabled = 1 ORDER BY sort ASC") + if err != nil { + return nil, fmt.Errorf("failed to query enabled cards: %w", err) + } + defer rows.Close() + + var cards []Card + for rows.Next() { + var c Card + var enabled int + if err := rows.Scan(&c.ID, &c.Icon, &c.Title, &c.Subtitle, &c.URL, &c.Sort, &enabled, &c.CreatedAt); err != nil { + return nil, fmt.Errorf("failed to scan card: %w", err) + } + c.Enabled = enabled == 1 + cards = append(cards, c) + } + return cards, rows.Err() +} + +// GetCardByID returns a single card by its ID. +func GetCardByID(id int) (*Card, error) { + var c Card + var enabled int + err := database.DB.QueryRow( + "SELECT id, icon, title, subtitle, url, sort, enabled, created_at FROM cards WHERE id = ?", + id, + ).Scan(&c.ID, &c.Icon, &c.Title, &c.Subtitle, &c.URL, &c.Sort, &enabled, &c.CreatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("failed to query card by id: %w", err) + } + c.Enabled = enabled == 1 + return &c, nil +} + +// CreateCard inserts a new card with sort = MAX(sort) + 1. +func CreateCard(card *Card) error { + var maxSort sql.NullInt64 + err := database.DB.QueryRow("SELECT MAX(sort) FROM cards").Scan(&maxSort) + if err != nil { + return fmt.Errorf("failed to get max sort: %w", err) + } + + newSort := 0 + if maxSort.Valid { + newSort = int(maxSort.Int64) + 1 + } + + enabledInt := 0 + if card.Enabled { + enabledInt = 1 + } + + result, err := database.DB.Exec( + "INSERT INTO cards (icon, title, subtitle, url, sort, enabled) VALUES (?, ?, ?, ?, ?, ?)", + card.Icon, card.Title, card.Subtitle, card.URL, newSort, enabledInt, + ) + if err != nil { + return fmt.Errorf("failed to insert card: %w", err) + } + + lastID, err := result.LastInsertId() + if err != nil { + return fmt.Errorf("failed to get last insert id: %w", err) + } + card.ID = int(lastID) + card.Sort = newSort + return nil +} + +// UpdateCard updates an existing card by ID. +func UpdateCard(card *Card) error { + enabledInt := 0 + if card.Enabled { + enabledInt = 1 + } + + _, err := database.DB.Exec( + "UPDATE cards SET icon = ?, title = ?, subtitle = ?, url = ?, sort = ?, enabled = ? WHERE id = ?", + card.Icon, card.Title, card.Subtitle, card.URL, card.Sort, enabledInt, card.ID, + ) + if err != nil { + return fmt.Errorf("failed to update card: %w", err) + } + return nil +} + +// DeleteCard deletes a card by ID. +func DeleteCard(id int) error { + _, err := database.DB.Exec("DELETE FROM cards WHERE id = ?", id) + if err != nil { + return fmt.Errorf("failed to delete card: %w", err) + } + return nil +} + +// ToggleCard toggles the enabled status of a card. +func ToggleCard(id int) error { + _, err := database.DB.Exec("UPDATE cards SET enabled = CASE WHEN enabled = 1 THEN 0 ELSE 1 END WHERE id = ?", id) + if err != nil { + return fmt.Errorf("failed to toggle card: %w", err) + } + return nil +} + +// MoveCardUp swaps the sort value of the card with the one above it. +func MoveCardUp(id int) error { + // Get the current card + card, err := GetCardByID(id) + if err != nil || card == nil { + return fmt.Errorf("card not found: %d", id) + } + + // Find the card with the next lower sort value (the one above) + var aboveCard Card + var aboveEnabled int + err = database.DB.QueryRow( + "SELECT id, icon, title, subtitle, url, sort, enabled, created_at FROM cards WHERE sort < ? ORDER BY sort DESC LIMIT 1", + card.Sort, + ).Scan(&aboveCard.ID, &aboveCard.Icon, &aboveCard.Title, &aboveCard.Subtitle, &aboveCard.URL, &aboveCard.Sort, &aboveEnabled, &aboveCard.CreatedAt) + if err == sql.ErrNoRows { + // Already at the top + return nil + } + if err != nil { + return fmt.Errorf("failed to find card above: %w", err) + } + aboveCard.Enabled = aboveEnabled == 1 + + // Swap sort values + _, err = database.DB.Exec("UPDATE cards SET sort = ? WHERE id = ?", aboveCard.Sort, card.ID) + if err != nil { + return fmt.Errorf("failed to swap sort (current): %w", err) + } + _, err = database.DB.Exec("UPDATE cards SET sort = ? WHERE id = ?", card.Sort, aboveCard.ID) + if err != nil { + return fmt.Errorf("failed to swap sort (above): %w", err) + } + + return nil +} + +// MoveCardDown swaps the sort value of the card with the one below it. +func MoveCardDown(id int) error { + // Get the current card + card, err := GetCardByID(id) + if err != nil || card == nil { + return fmt.Errorf("card not found: %d", id) + } + + // Find the card with the next higher sort value (the one below) + var belowCard Card + var belowEnabled int + err = database.DB.QueryRow( + "SELECT id, icon, title, subtitle, url, sort, enabled, created_at FROM cards WHERE sort > ? ORDER BY sort ASC LIMIT 1", + card.Sort, + ).Scan(&belowCard.ID, &belowCard.Icon, &belowCard.Title, &belowCard.Subtitle, &belowCard.URL, &belowCard.Sort, &belowEnabled, &belowCard.CreatedAt) + if err == sql.ErrNoRows { + // Already at the bottom + return nil + } + if err != nil { + return fmt.Errorf("failed to find card below: %w", err) + } + belowCard.Enabled = belowEnabled == 1 + + // Swap sort values + _, err = database.DB.Exec("UPDATE cards SET sort = ? WHERE id = ?", belowCard.Sort, card.ID) + if err != nil { + return fmt.Errorf("failed to swap sort (current): %w", err) + } + _, err = database.DB.Exec("UPDATE cards SET sort = ? WHERE id = ?", card.Sort, belowCard.ID) + if err != nil { + return fmt.Errorf("failed to swap sort (below): %w", err) + } + + return nil +} diff --git a/models/ipban.go b/models/ipban.go new file mode 100644 index 0000000..c05371e --- /dev/null +++ b/models/ipban.go @@ -0,0 +1,79 @@ +package models + +import ( + "database/sql" + "fmt" + "time" + + "simple_portal/database" +) + +// IPBan 表示一条IP封禁记录。 +type IPBan struct { + ID int `json:"id"` + IP string `json:"ip"` + Reason string `json:"reason"` + FailCount int `json:"fail_count"` + BannedUntil time.Time `json:"banned_until"` + CreatedAt time.Time `json:"created_at"` +} + +// IsIPBanned 检查指定IP是否处于封禁状态(未过期)。 +// 白名单IP不会被检查,需要在调用前自行判断。 +func IsIPBanned(ip string) (bool, *IPBan, error) { + var ban IPBan + err := database.DB.QueryRow( + "SELECT id, ip, reason, fail_count, banned_until, created_at FROM ip_bans WHERE ip = ? AND banned_until > ? ORDER BY banned_until DESC LIMIT 1", + ip, time.Now().Format("2006-01-02 15:04:05"), + ).Scan(&ban.ID, &ban.IP, &ban.Reason, &ban.FailCount, &ban.BannedUntil, &ban.CreatedAt) + if err == sql.ErrNoRows { + return false, nil, nil + } + if err != nil { + return false, nil, fmt.Errorf("failed to check IP ban: %w", err) + } + return true, &ban, nil +} + +// CreateIPBan 插入一条IP封禁记录。 +func CreateIPBan(ip, reason string, failCount int, bannedUntil time.Time) error { + _, err := database.DB.Exec( + "INSERT INTO ip_bans (ip, reason, fail_count, banned_until, created_at) VALUES (?, ?, ?, ?, ?)", + ip, reason, failCount, bannedUntil.Format("2006-01-02 15:04:05"), time.Now().Format("2006-01-02 15:04:05"), + ) + if err != nil { + return fmt.Errorf("failed to create IP ban: %w", err) + } + return nil +} + +// GetAllActiveBans 获取所有未过期的封禁记录。 +func GetAllActiveBans() ([]IPBan, error) { + rows, err := database.DB.Query( + "SELECT id, ip, reason, fail_count, banned_until, created_at FROM ip_bans WHERE banned_until > ? ORDER BY banned_until DESC", + time.Now().Format("2006-01-02 15:04:05"), + ) + if err != nil { + return nil, fmt.Errorf("failed to query active bans: %w", err) + } + defer rows.Close() + + var bans []IPBan + for rows.Next() { + var b IPBan + if err := rows.Scan(&b.ID, &b.IP, &b.Reason, &b.FailCount, &b.BannedUntil, &b.CreatedAt); err != nil { + return nil, fmt.Errorf("failed to scan IP ban: %w", err) + } + bans = append(bans, b) + } + return bans, nil +} + +// DeleteIPBan 删除一条封禁记录(手动解封)。 +func DeleteIPBan(id int) error { + _, err := database.DB.Exec("DELETE FROM ip_bans WHERE id = ?", id) + if err != nil { + return fmt.Errorf("failed to delete IP ban: %w", err) + } + return nil +} diff --git a/models/ipwhitelist.go b/models/ipwhitelist.go new file mode 100644 index 0000000..4bcf413 --- /dev/null +++ b/models/ipwhitelist.go @@ -0,0 +1,77 @@ +package models + +import ( + "fmt" + "time" + + "simple_portal/database" +) + +// IPWhitelist 表示一条IP白名单记录。 +type IPWhitelist struct { + ID int `json:"id"` + IP string `json:"ip"` + Comment string `json:"comment"` + CreatedAt time.Time `json:"created_at"` +} + +// HasWhitelist 检查是否存在白名单记录。 +// 如果没有白名单记录,则不限制任何IP。 +func HasWhitelist() (bool, error) { + var count int + err := database.DB.QueryRow("SELECT COUNT(*) FROM ip_whitelist").Scan(&count) + if err != nil { + return false, fmt.Errorf("failed to check whitelist: %w", err) + } + return count > 0, nil +} + +// IsIPWhitelisted 检查IP是否在白名单中。 +func IsIPWhitelisted(ip string) (bool, error) { + var count int + err := database.DB.QueryRow("SELECT COUNT(*) FROM ip_whitelist WHERE ip = ?", ip).Scan(&count) + if err != nil { + return false, fmt.Errorf("failed to check IP whitelist: %w", err) + } + return count > 0, nil +} + +// GetAllWhitelist 获取所有白名单记录。 +func GetAllWhitelist() ([]IPWhitelist, error) { + rows, err := database.DB.Query("SELECT id, ip, comment, created_at FROM ip_whitelist ORDER BY created_at DESC") + if err != nil { + return nil, fmt.Errorf("failed to query whitelist: %w", err) + } + defer rows.Close() + + var list []IPWhitelist + for rows.Next() { + var w IPWhitelist + if err := rows.Scan(&w.ID, &w.IP, &w.Comment, &w.CreatedAt); err != nil { + return nil, fmt.Errorf("failed to scan whitelist: %w", err) + } + list = append(list, w) + } + return list, nil +} + +// AddWhitelist 添加一条白名单记录。 +func AddWhitelist(ip, comment string) error { + _, err := database.DB.Exec( + "INSERT INTO ip_whitelist (ip, comment, created_at) VALUES (?, ?, ?)", + ip, comment, time.Now(), + ) + if err != nil { + return fmt.Errorf("failed to add whitelist: %w", err) + } + return nil +} + +// DeleteWhitelist 删除一条白名单记录。 +func DeleteWhitelist(id int) error { + _, err := database.DB.Exec("DELETE FROM ip_whitelist WHERE id = ?", id) + if err != nil { + return fmt.Errorf("failed to delete whitelist: %w", err) + } + return nil +} diff --git a/models/loginlog.go b/models/loginlog.go new file mode 100644 index 0000000..5929a52 --- /dev/null +++ b/models/loginlog.go @@ -0,0 +1,73 @@ +package models + +import ( + "fmt" + "time" + + "simple_portal/database" +) + +// LoginLog 表示一条登录日志记录。 +type LoginLog struct { + ID int `json:"id"` + AdminID *int `json:"admin_id"` + Username string `json:"username"` + IP string `json:"ip"` + UserAgent string `json:"user_agent"` + Success bool `json:"success"` + CreatedAt time.Time `json:"created_at"` +} + +// CreateLoginLog 插入一条登录日志。 +func CreateLoginLog(adminID *int, username, ip, userAgent string, success bool) error { + _, err := database.DB.Exec( + "INSERT INTO login_logs (admin_id, username, ip, user_agent, success, created_at) VALUES (?, ?, ?, ?, ?, ?)", + adminID, username, ip, userAgent, success, time.Now().Format("2006-01-02 15:04:05"), + ) + if err != nil { + return fmt.Errorf("failed to create login log: %w", err) + } + return nil +} + +// LoginLogCount 返回登录日志总数。 +func LoginLogCount() (int, error) { + var count int + err := database.DB.QueryRow("SELECT COUNT(*) FROM login_logs").Scan(&count) + if err != nil { + return 0, fmt.Errorf("failed to count login logs: %w", err) + } + return count, nil +} + +// GetLoginLogs 分页获取登录日志,倒序排列。 +func GetLoginLogs(page, pageSize int) ([]LoginLog, int, error) { + total, err := LoginLogCount() + if err != nil { + return nil, 0, err + } + + offset := (page - 1) * pageSize + if offset < 0 { + offset = 0 + } + + rows, err := database.DB.Query( + "SELECT id, admin_id, username, ip, user_agent, success, created_at FROM login_logs ORDER BY created_at DESC LIMIT ? OFFSET ?", + pageSize, offset, + ) + if err != nil { + return nil, 0, fmt.Errorf("failed to query login logs: %w", err) + } + defer rows.Close() + + var logs []LoginLog + for rows.Next() { + var l LoginLog + if err := rows.Scan(&l.ID, &l.AdminID, &l.Username, &l.IP, &l.UserAgent, &l.Success, &l.CreatedAt); err != nil { + return nil, 0, fmt.Errorf("failed to scan login log: %w", err) + } + logs = append(logs, l) + } + return logs, total, nil +} diff --git a/models/setting.go b/models/setting.go new file mode 100644 index 0000000..f63b3aa --- /dev/null +++ b/models/setting.go @@ -0,0 +1,55 @@ +package models + +import ( + "database/sql" + "fmt" + + "simple_portal/database" +) + +// Predefined search engine URL templates. +const ( + SearchEngineGoogle = "https://www.google.com/search?q=%s" + SearchEngineBing = "https://www.bing.com/search?q=%s" + SearchEngineBaidu = "https://www.baidu.com/s?wd=%s" +) + +// SettingKey constants. +const ( + SettingKeySearchEngine = "search_engine" + SettingKeyHomepageTitle = "homepage_title" + SettingKeyHomepageSubtitle = "homepage_subtitle" + SettingKeyHomepageBackground = "homepage_background" +) + +// Default setting values. +const ( + DefaultHomepageTitle = "Portal" + DefaultHomepageSubtitle = "" + DefaultHomepageBackground = "" +) + +// GetSetting retrieves a setting value by key. Returns empty string if not found. +func GetSetting(key string) (string, error) { + var value sql.NullString + err := database.DB.QueryRow("SELECT value FROM settings WHERE key = ?", key).Scan(&value) + if err == sql.ErrNoRows { + return "", nil + } + if err != nil { + return "", fmt.Errorf("failed to get setting %s: %w", key, err) + } + return value.String, nil +} + +// SetSetting inserts or updates a setting value by key. +func SetSetting(key, value string) error { + _, err := database.DB.Exec( + "INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?", + key, value, value, + ) + if err != nil { + return fmt.Errorf("failed to set setting %s: %w", key, err) + } + return nil +} diff --git a/session/session.go b/session/session.go new file mode 100644 index 0000000..3c82f6c --- /dev/null +++ b/session/session.go @@ -0,0 +1,65 @@ +package session + +import ( + "crypto/rand" + "encoding/hex" + "sync" + "time" +) + +// SessionData holds the data stored in a session. +type SessionData struct { + AdminID int + Username string + CreatedAt time.Time +} + +// SessionStore is an in-memory session store with RWMutex for concurrent safety. +type SessionStore struct { + store map[string]*SessionData + mu sync.RWMutex +} + +// NewSessionStore creates a new SessionStore instance. +func NewSessionStore() *SessionStore { + return &SessionStore{ + store: make(map[string]*SessionData), + } +} + +// Create creates a new session for the given admin and returns the session ID. +func (s *SessionStore) Create(adminID int, username string) string { + sessionID := generateSessionID() + data := &SessionData{ + AdminID: adminID, + Username: username, + CreatedAt: time.Now(), + } + + s.mu.Lock() + s.store[sessionID] = data + s.mu.Unlock() + + return sessionID +} + +// Get retrieves session data by session ID. Returns nil if not found. +func (s *SessionStore) Get(sessionID string) *SessionData { + s.mu.RLock() + defer s.mu.RUnlock() + return s.store[sessionID] +} + +// Delete removes a session by session ID. +func (s *SessionStore) Delete(sessionID string) { + s.mu.Lock() + delete(s.store, sessionID) + s.mu.Unlock() +} + +// generateSessionID creates a cryptographically random session ID. +func generateSessionID() string { + b := make([]byte, 32) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..9dba3a5 --- /dev/null +++ b/static/style.css @@ -0,0 +1,792 @@ +/* ===== Reset & Base ===== */ +*, +*::before, +*::after { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans SC", sans-serif; + color: #333; + background: #f5f5f5; + line-height: 1.6; +} + +a { + color: #1a73e8; + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +/* ===== Home Page ===== */ +.home-container { + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + padding: 80px 20px 40px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); +} + +.home-header { + text-align: center; + margin-bottom: 40px; +} + +.home-title { + font-size: 48px; + font-weight: 700; + color: #fff; + letter-spacing: 2px; + margin-bottom: 8px; +} + +.home-subtitle { + font-size: 16px; + color: rgba(255, 255, 255, 0.8); +} + +/* ===== Search Box ===== */ +.search-box { + width: 100%; + max-width: 640px; + margin-bottom: 60px; +} + +.search-box form { + display: flex; + background: #fff; + border-radius: 24px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15); + overflow: hidden; + transition: box-shadow 0.2s; +} + +.search-box form:focus-within { + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25); +} + +.search-input { + flex: 1; + border: none; + outline: none; + padding: 14px 24px; + font-size: 16px; + background: transparent; + color: #333; +} + +.search-input::placeholder { + color: #aaa; +} + +.search-btn { + border: none; + background: #667eea; + color: #fff; + padding: 14px 28px; + font-size: 16px; + cursor: pointer; + transition: background 0.2s; +} + +.search-btn:hover { + background: #5a6fd6; +} + +/* ===== Card Grid ===== */ +.card-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 20px; + width: 100%; + max-width: 960px; +} + +@media (max-width: 768px) { + .card-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 480px) { + .card-grid { + grid-template-columns: 1fr; + } +} + +.card-item { + display: flex; + align-items: center; + gap: 12px; + background: #fff; + border-radius: 12px; + padding: 16px 20px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + transition: transform 0.2s, box-shadow 0.2s; + text-decoration: none; + color: #333; +} + +.card-item:hover { + transform: translateY(-4px); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); + text-decoration: none; +} + +.card-icon { + flex-shrink: 0; + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; +} + +.card-emoji { + font-size: 28px; +} + +.card-content { + min-width: 0; +} + +.card-title { + font-size: 15px; + font-weight: 600; + color: #333; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.card-subtitle { + font-size: 13px; + color: #999; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ===== Home Footer ===== */ +.home-footer { + margin-top: auto; + padding-top: 40px; +} + +.home-footer a { + color: rgba(255, 255, 255, 0.5); + font-size: 13px; +} + +.home-footer a:hover { + color: rgba(255, 255, 255, 0.8); + text-decoration: none; +} + +/* ===== Login Page ===== */ +.login-container { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: #f5f5f5; +} + +.login-card { + background: #fff; + border-radius: 12px; + padding: 40px; + width: 100%; + max-width: 400px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08); +} + +.login-title { + font-size: 24px; + font-weight: 600; + text-align: center; + margin-bottom: 24px; + color: #333; +} + +.login-error { + background: #fef2f2; + color: #dc2626; + padding: 10px 16px; + border-radius: 8px; + margin-bottom: 16px; + font-size: 14px; + text-align: center; +} + +.login-form .form-group { + margin-bottom: 16px; +} + +/* ===== Admin Layout ===== */ +.admin-layout { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.admin-nav { + display: flex; + align-items: center; + background: #1f2937; + color: #fff; + padding: 0 24px; + height: 56px; + gap: 24px; +} + +.admin-nav-brand { + font-size: 18px; + font-weight: 700; + margin-right: 16px; +} + +.admin-nav-links { + display: flex; + gap: 4px; +} + +.admin-nav-link { + color: rgba(255, 255, 255, 0.7); + padding: 8px 16px; + border-radius: 6px; + font-size: 14px; + transition: background 0.2s, color 0.2s; + text-decoration: none; +} + +.admin-nav-link:hover { + background: rgba(255, 255, 255, 0.1); + color: #fff; + text-decoration: none; +} + +.admin-nav-link.active { + background: rgba(255, 255, 255, 0.15); + color: #fff; +} + +.admin-nav-user { + margin-left: auto; + display: flex; + align-items: center; + gap: 12px; + font-size: 14px; + color: rgba(255, 255, 255, 0.8); +} + +.admin-main { + flex: 1; + padding: 32px; + max-width: 1200px; + width: 100%; + margin: 0 auto; +} + +.admin-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 24px; +} + +.admin-header h1 { + font-size: 24px; + font-weight: 600; + color: #111; +} + +/* ===== Admin Table ===== */ +.admin-table { + width: 100%; + border-collapse: collapse; + background: #fff; + border-radius: 8px; + overflow: hidden; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06); +} + +.admin-table th, +.admin-table td { + padding: 12px 16px; + text-align: left; + border-bottom: 1px solid #eee; + font-size: 14px; +} + +.admin-table th { + background: #f9fafb; + font-weight: 600; + color: #555; +} + +.admin-table td a { + color: #1a73e8; + max-width: 200px; + display: inline-block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + vertical-align: bottom; +} + +.admin-table td.actions { + white-space: nowrap; +} + +/* ===== Buttons ===== */ +.btn { + display: inline-block; + padding: 8px 16px; + border: none; + border-radius: 6px; + font-size: 14px; + cursor: pointer; + text-align: center; + transition: background 0.2s, opacity 0.2s; + text-decoration: none; + line-height: 1.5; +} + +.btn:hover { + opacity: 0.9; + text-decoration: none; +} + +.btn-primary { + background: #667eea; + color: #fff; +} + +.btn-primary:hover { + background: #5a6fd6; +} + +.btn-secondary { + background: #e5e7eb; + color: #374151; +} + +.btn-secondary:hover { + background: #d1d5db; +} + +.btn-danger { + background: #ef4444; + color: #fff; +} + +.btn-danger:hover { + background: #dc2626; +} + +.btn-sm { + padding: 4px 10px; + font-size: 12px; +} + +.btn-block { + display: block; + width: 100%; +} + +/* ===== Forms ===== */ +.form-group { + margin-bottom: 20px; +} + +.form-group label { + display: block; + margin-bottom: 6px; + font-size: 14px; + font-weight: 500; + color: #374151; +} + +.form-group input[type="text"], +.form-group input[type="password"], +.form-group input[type="url"], +.form-group select { + width: 100%; + padding: 10px 14px; + border: 1px solid #d1d5db; + border-radius: 8px; + font-size: 14px; + transition: border-color 0.2s; + outline: none; + background: #fff; +} + +.form-group input:focus, +.form-group select:focus { + border-color: #667eea; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.15); +} + +.form-group input[type="checkbox"] { + margin-right: 6px; + width: auto; +} + +.input-readonly { + background: #f3f4f6 !important; + color: #6b7280; + cursor: not-allowed; +} + +.required { + color: #ef4444; +} + +.form-actions { + display: flex; + gap: 12px; + margin-top: 24px; +} + +.form-error { + background: #fef2f2; + color: #dc2626; + padding: 10px 16px; + border-radius: 8px; + margin-bottom: 16px; + font-size: 14px; +} + +/* ===== Badges ===== */ +.badge { + display: inline-block; + padding: 2px 10px; + border-radius: 12px; + font-size: 12px; + font-weight: 500; +} + +.badge-success { + background: #dcfce7; + color: #16a34a; +} + +.badge-secondary { + background: #f3f4f6; + color: #6b7280; +} + +.form-success { + background: #f0fdf4; + color: #16a34a; + padding: 10px 16px; + border-radius: 8px; + margin-bottom: 16px; + font-size: 14px; +} + +/* ===== Home Page with Background ===== */ +.home-container.has-background { + position: relative; + background: none; +} + +.home-container.has-background::before { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.35); + z-index: 0; +} + +.home-container.has-background > * { + position: relative; + z-index: 1; +} + +/* ===== Site Subtitle ===== */ +.site-subtitle { + font-size: 16px; + color: rgba(255, 255, 255, 0.8); + margin-top: 4px; +} + +/* ===== Card Icon Image ===== */ +.card-icon-img { + width: 40px; + height: 40px; + object-fit: contain; + border-radius: 4px; +} + +/* ===== Upload Button ===== */ +.upload-btn-wrapper { + display: flex; + align-items: center; + gap: 8px; + margin-top: 8px; +} + +.upload-btn { + display: inline-block; + padding: 6px 14px; + background: #667eea; + color: #fff; + border: none; + border-radius: 6px; + font-size: 13px; + cursor: pointer; + transition: background 0.2s; + line-height: 1.5; +} + +.upload-btn:hover { + background: #5a6fd6; +} + +.upload-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.upload-status { + font-size: 13px; + margin-left: 4px; +} + +.upload-success { + color: #16a34a; +} + +.upload-error { + color: #dc2626; +} + +/* ===== Background Preview ===== */ +.background-preview { + margin-top: 10px; +} + +.upload-preview-img { + max-width: 320px; + max-height: 180px; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + display: block; + margin-bottom: 6px; +} + +.preview-link { + font-size: 13px; + color: #1a73e8; +} + +/* ===== Form Section Title ===== */ +.form-section-title { + font-size: 18px; + font-weight: 600; + color: #374151; + margin: 32px 0 16px; + padding-bottom: 8px; + border-bottom: 1px solid #e5e7eb; +} + +.form-section-title:first-of-type { + margin-top: 0; +} + +/* ===== Badges — danger variant ===== */ +.badge-danger { + background: #fef2f2; + color: #dc2626; +} + +/* ===== Pagination ===== */ +.pagination { + display: flex; + align-items: center; + gap: 12px; + margin-top: 20px; + padding: 12px 0; +} + +.pagination-info { + font-size: 14px; + color: #6b7280; +} + +/* ===== User-Agent cell truncation ===== */ +.ua-cell { + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + color: #9ca3af; +} + +/* ===== Code style ===== */ +code { + background: #f3f4f6; + padding: 2px 6px; + border-radius: 4px; + font-family: "Cascadia Code", "Fira Code", "Consolas", monospace; + font-size: 13px; +} + +/* ===== Admin nav responsive ===== */ +@media (max-width: 900px) { + .admin-nav { + flex-wrap: wrap; + height: auto; + padding: 8px 16px; + gap: 8px; + } + .admin-nav-links { + order: 3; + width: 100%; + flex-wrap: wrap; + gap: 2px; + } + .admin-nav-link { + padding: 4px 10px; + font-size: 13px; + } +} + +/* ===== Whitelist Notice ===== */ +.whitelist-notice { + background: #fef3c7; + color: #92400e; + padding: 12px 16px; + border-radius: 8px; + margin-bottom: 20px; + font-size: 14px; + border: 1px solid #fbbf24; +} + +.whitelist-notice.notice-info { + background: #eff6ff; + color: #1e40af; + border-color: #93c5fd; +} + +/* ===== Stats Grid ===== */ +.stats-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 20px; + margin-bottom: 32px; +} + +@media (max-width: 768px) { + .stats-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +.stat-card { + background: #fff; + border-radius: 12px; + padding: 24px; + text-align: center; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06); + transition: transform 0.2s, box-shadow 0.2s; +} + +.stat-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +.stat-value { + font-size: 36px; + font-weight: 700; + color: #667eea; + margin-bottom: 4px; +} + +.stat-label { + font-size: 14px; + color: #6b7280; +} + +/* ===== Badge Variants ===== */ +.badge-primary { + background: #eff6ff; + color: #2563eb; +} + +.badge-warning { + background: #fffbeb; + color: #d97706; +} + +/* ===== Detail Cell ===== */ +.detail-cell { + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; +} + +/* ===== Filter Form ===== */ +.filter-form { + background: #fff; + border-radius: 8px; + padding: 16px 20px; + margin-bottom: 20px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06); +} + +.filter-row { + display: flex; + gap: 16px; + align-items: flex-end; + flex-wrap: wrap; +} + +.filter-group { + display: flex; + flex-direction: column; + gap: 4px; +} + +.filter-group label { + font-size: 13px; + font-weight: 500; + color: #374151; +} + +.filter-group input[type="text"], +.filter-group select { + padding: 6px 12px; + border: 1px solid #d1d5db; + border-radius: 6px; + font-size: 14px; + outline: none; + min-width: 160px; +} + +.filter-group input:focus, +.filter-group select:focus { + border-color: #667eea; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.15); +} + +.filter-actions-group { + flex-direction: row; + align-items: center; + gap: 8px; +} diff --git a/static/upload.js b/static/upload.js new file mode 100644 index 0000000..b58c81f --- /dev/null +++ b/static/upload.js @@ -0,0 +1,140 @@ +/** + * upload.js — Generic file upload handler for Portal admin pages. + * + * Usage: + * setupUpload(inputSelector, uploadType) + * - inputSelector: CSS selector for the target input field + * - uploadType: "icon" or "background" — determines thumbnail dimensions + * + * Adds a hidden file input and a visible "上传图片" button next to the target input. + * After successful upload, the target input value is set to the returned URL. + */ + +(function () { + "use strict"; + + /** + * Sets up an upload button next to the specified input field. + * @param {string} inputSelector - CSS selector for the input to fill with the URL + * @param {string} uploadType - "icon" or "background" + */ + function setupUpload(inputSelector, uploadType) { + var input = document.querySelector(inputSelector); + if (!input) return; + + // Create container for the upload button group + var wrapper = document.createElement("div"); + wrapper.className = "upload-btn-wrapper"; + + // Create the visible upload button + var btn = document.createElement("button"); + btn.type = "button"; + btn.className = "upload-btn"; + btn.textContent = "上传图片"; + + // Create the hidden file input + var fileInput = document.createElement("input"); + fileInput.type = "file"; + fileInput.accept = "image/jpeg,image/png,image/gif"; + fileInput.style.display = "none"; + + // Create status message element + var status = document.createElement("span"); + status.className = "upload-status"; + + // Insert wrapper after the input + input.parentNode.insertBefore(wrapper, input.nextSibling); + wrapper.appendChild(btn); + wrapper.appendChild(fileInput); + wrapper.appendChild(status); + + // Click button -> open file dialog + btn.addEventListener("click", function () { + fileInput.click(); + }); + + // File selected -> upload + fileInput.addEventListener("change", function () { + if (fileInput.files.length === 0) return; + + var file = fileInput.files[0]; + + // Validate file size (5MB) + if (file.size > 5 * 1024 * 1024) { + status.textContent = "文件太大,最大允许 5MB"; + status.className = "upload-status upload-error"; + return; + } + + // Validate file type + var validTypes = ["image/jpeg", "image/png", "image/gif"]; + if (validTypes.indexOf(file.type) === -1) { + status.textContent = "不支持的文件格式"; + status.className = "upload-status upload-error"; + return; + } + + // Start upload + btn.disabled = true; + btn.textContent = "上传中..."; + status.textContent = ""; + status.className = "upload-status"; + + var formData = new FormData(); + formData.append("file", file); + formData.append("type", uploadType); + + var xhr = new XMLHttpRequest(); + xhr.open("POST", "/admin/upload", true); + + xhr.onload = function () { + btn.disabled = false; + btn.textContent = "上传图片"; + + if (xhr.status === 200) { + try { + var resp = JSON.parse(xhr.responseText); + if (resp.url) { + input.value = resp.url; + status.textContent = "上传成功!"; + status.className = "upload-status upload-success"; + + // If there's a preview element, update it + var preview = input.parentNode.querySelector(".upload-preview"); + if (preview) { + preview.src = resp.url + "?thumb=1"; + preview.style.display = "block"; + } + } else { + status.textContent = resp.error || "上传失败"; + status.className = "upload-status upload-error"; + } + } catch (e) { + status.textContent = "上传失败"; + status.className = "upload-status upload-error"; + } + } else { + try { + var resp = JSON.parse(xhr.responseText); + status.textContent = resp.error || "上传失败"; + } catch (e) { + status.textContent = "上传失败 (" + xhr.status + ")"; + } + status.className = "upload-status upload-error"; + } + }; + + xhr.onerror = function () { + btn.disabled = false; + btn.textContent = "上传图片"; + status.textContent = "网络错误"; + status.className = "upload-status upload-error"; + }; + + xhr.send(formData); + }); + } + + // Expose globally + window.setupUpload = setupUpload; +})(); diff --git a/templates/admin/403.html b/templates/admin/403.html new file mode 100644 index 0000000..adc076e --- /dev/null +++ b/templates/admin/403.html @@ -0,0 +1,11 @@ +{{define "admin/403.html"}} +{{template "header" .}} +
+ +
+{{template "footer" .}} +{{end}} diff --git a/templates/admin/access_logs.html b/templates/admin/access_logs.html new file mode 100644 index 0000000..265eb70 --- /dev/null +++ b/templates/admin/access_logs.html @@ -0,0 +1,97 @@ +{{define "admin/access_logs.html"}} +{{template "header" .}} +
+ +
+

访问日志

+ + +
+
+
+ + +
+
+ + +
+
+ + 重置 +
+
+
+ + + + + + + + + + + + + + {{range .Logs}} + + + + + + + + + {{end}} + {{if not .Logs}} + + + + {{end}} + +
时间IP地址类型详情来源客户端
{{.CreatedAt.Format "2006-01-02 15:04:05"}}{{.IP}} + {{if eq .ActionType "visit"}}访问 + {{else if eq .ActionType "click"}}点击 + {{else if eq .ActionType "search"}}搜索 + {{else}}{{.ActionType}}{{end}} + {{if .Detail}}{{.Detail}}{{else}}—{{end}}{{if .Referer}}来源{{else}}—{{end}}{{.UserAgent}}
暂无访问日志
+ + {{if gt .TotalPages 1}} + + {{end}} +
+
+{{template "footer" .}} +{{end}} diff --git a/templates/admin/card_form.html b/templates/admin/card_form.html new file mode 100644 index 0000000..2262e3f --- /dev/null +++ b/templates/admin/card_form.html @@ -0,0 +1,64 @@ +{{define "admin/card_form.html"}} +{{template "header" .}} +
+ +
+

{{if .IsEdit}}编辑卡片{{else}}新建卡片{{end}}

+ + {{if .Error}}
{{.Error}}
{{end}} + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + 取消 +
+
+
+
+ + +{{template "footer" .}} +{{end}} diff --git a/templates/admin/cards.html b/templates/admin/cards.html new file mode 100644 index 0000000..8cf208b --- /dev/null +++ b/templates/admin/cards.html @@ -0,0 +1,82 @@ +{{define "admin/cards.html"}} +{{template "header" .}} +
+ +
+
+

卡片管理

+ + 新建卡片 +
+ + + + + + + + + + + + + + + {{range .Cards}} + + + + + + + + + + {{end}} + {{if not .Cards}} + + + + {{end}} + +
图标标题副标题链接排序状态操作
{{if .Icon}}{{.Icon}}{{else}}—{{end}}{{.Title}}{{if .Subtitle}}{{.Subtitle}}{{else}}—{{end}}{{.URL}}{{.Sort}} + {{if .Enabled}} + 启用 + {{else}} + 禁用 + {{end}} + +
+ +
+
+ +
+
+ +
+ 编辑 +
+ +
+
暂无卡片,点击"新建卡片"添加
+
+
+{{template "footer" .}} +{{end}} diff --git a/templates/admin/index.html b/templates/admin/index.html new file mode 100644 index 0000000..430903c --- /dev/null +++ b/templates/admin/index.html @@ -0,0 +1,109 @@ +{{define "admin/index.html"}} +{{template "header" .}} +
+ +
+

管理后台

+ + +
+
+
{{.Stats.TodayViews}}
+
今日浏览
+
+
+
{{.Stats.TotalViews}}
+
总浏览次数
+
+
+
{{.Stats.NewIPs}}
+
今日新IP
+
+
+
{{.Stats.TotalIPs}}
+
总IP数量
+
+
+ + +

实时流量

+ {{if .RecentLogs}} + + + + + + + + + + + + {{range .RecentLogs}} + + + + + + + + {{end}} + +
时间IP类型详情客户端
{{.CreatedAt.Format "15:04:05"}}{{.IP}} + {{if eq .ActionType "visit"}}访问 + {{else if eq .ActionType "click"}}点击 + {{else if eq .ActionType "search"}}搜索 + {{else}}{{.ActionType}}{{end}} + {{if .Detail}}{{.Detail}}{{else}}—{{end}}{{.UserAgent}}
+ {{else}} +

暂无访问记录

+ {{end}} + + + {{if .IPStats}} +

IP 访问排行 (Top 10)

+ + + + + + + + + + {{range .IPStats}} + + + + + + {{end}} + +
IP地址访问次数最后访问
{{.IP}}{{.Visits}}{{.LastSeen}}
+ {{end}} +
+
+ + +{{template "footer" .}} +{{end}} diff --git a/templates/admin/ip_whitelist.html b/templates/admin/ip_whitelist.html new file mode 100644 index 0000000..f0f85d7 --- /dev/null +++ b/templates/admin/ip_whitelist.html @@ -0,0 +1,85 @@ +{{define "admin/ip_whitelist.html"}} +{{template "header" .}} +
+ +
+

IP 白名单管理

+ + {{if .HasWhitelist}} +
+ ⚠️ 白名单模式已启用:当前仅白名单中的IP可以访问后台管理页面,非白名单IP将被拒绝访问。 +
+ {{else}} +
+ ℹ️ 白名单模式未启用:白名单为空时,不限制任何IP访问后台。添加至少一条记录即可启用白名单模式。 +
+ {{end}} + + {{if .Error}}
{{.Error}}
{{end}} + {{if .Message}}
{{.Message}}
{{end}} + +

添加白名单

+
+
+ + +
+
+ + +
+
+ +
+
+ +

当前白名单

+ {{if .Whitelist}} + + + + + + + + + + + {{range .Whitelist}} + + + + + + + {{end}} + +
IP 地址备注添加时间操作
{{.IP}}{{if .Comment}}{{.Comment}}{{else}}—{{end}}{{.CreatedAt.Format "2006-01-02 15:04:05"}} +
+ +
+
+ {{else}} +

暂无白名单记录。添加记录后,将仅允许白名单IP访问后台。

+ {{end}} +
+
+{{template "footer" .}} +{{end}} diff --git a/templates/admin/login.html b/templates/admin/login.html new file mode 100644 index 0000000..9b284c1 --- /dev/null +++ b/templates/admin/login.html @@ -0,0 +1,21 @@ +{{define "admin/login.html"}} +{{template "header" .}} +
+ +
+{{template "footer" .}} +{{end}} diff --git a/templates/admin/logs.html b/templates/admin/logs.html new file mode 100644 index 0000000..702c4e3 --- /dev/null +++ b/templates/admin/logs.html @@ -0,0 +1,105 @@ +{{define "admin/logs.html"}} +{{template "header" .}} +
+ +
+

登录日志

+ + + + + + + + + + + + + {{range .Logs}} + + + + + + + + {{end}} + {{if not .Logs}} + + + + {{end}} + +
时间用户名IP地址User-Agent状态
{{.CreatedAt.Format "2006-01-02 15:04:05"}}{{.Username}}{{.IP}}{{.UserAgent}} + {{if .Success}} + 成功 + {{else}} + 失败 + {{end}} +
暂无登录日志
+ + {{if gt .TotalPages 1}} + + {{end}} + +

IP 封禁列表

+ {{if .Bans}} + + + + + + + + + + + + {{range .Bans}} + + + + + + + + {{end}} + +
IP地址原因失败次数封禁至操作
{{.IP}}{{.Reason}}{{.FailCount}}{{.BannedUntil.Format "2006-01-02 15:04:05"}} +
+ +
+
+ {{else}} +

当前无封禁IP

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

修改密码

+ + {{if .Error}}
{{.Error}}
{{end}} + {{if .Message}}
{{.Message}}
{{end}} + +
+
+ + +
+
+ + + 密码长度不少于6位 +
+
+ + +
+
+ +
+
+
+
+{{template "footer" .}} +{{end}} diff --git a/templates/admin/settings.html b/templates/admin/settings.html new file mode 100644 index 0000000..a742a8b --- /dev/null +++ b/templates/admin/settings.html @@ -0,0 +1,80 @@ +{{define "admin/settings.html"}} +{{template "header" .}} +
+ +
+

设置

+ + {{if .Error}}
{{.Error}}
{{end}} + {{if .Message}}
{{.Message}}
{{end}} + +
+

搜索引擎

+
+ + +
+
+ + +
+
+ + +
+ +

主页配置

+
+ + +
+
+ + +
+
+ + + {{if .HomepageBackground}} +
+ 背景预览 + 查看原图 +
+ {{end}} +
+ +
+ +
+
+
+
+ + +{{template "footer" .}} +{{end}} diff --git a/templates/home.html b/templates/home.html new file mode 100644 index 0000000..2d0265a --- /dev/null +++ b/templates/home.html @@ -0,0 +1,39 @@ +{{define "home.html"}} +{{template "header" .}} +
+
+

{{.SiteTitle}}

+ {{if .SiteSubtitle}}

{{.SiteSubtitle}}

{{else}}

快速导航,一键直达

{{end}} +
+ + + + + + +
+ + +{{template "footer" .}} +{{end}} diff --git a/templates/layout.html b/templates/layout.html new file mode 100644 index 0000000..5049bf6 --- /dev/null +++ b/templates/layout.html @@ -0,0 +1,16 @@ +{{define "header"}} + + + + + + {{.Title}} + + + +{{end}} + +{{define "footer"}} + + +{{end}}