feat: 门户网站初始提交

- Go + Gin + html/template 服务端渲染
- 主页:Google 风格搜索框 + 导航卡片
- 后台:卡片 CRUD、搜索引擎配置、主页背景/标题配置
- 图片上传:支持 jpg/jpeg/png/gif,自动压缩,缩略图参数 ?thumb=1
- 安全:登录日志、修改密码、IP 自动封禁、IP 白名单
- 访问统计:主页访问/卡片点击/搜索追踪、实时流量、IP 统计
- SQLite 存储(modernc.org/sqlite,纯 Go)
- 内存 Session + bcrypt 密码哈希
This commit is contained in:
2026-05-28 13:54:07 +08:00
commit c16a8dfbc4
42 changed files with 5295 additions and 0 deletions
+193
View File
@@ -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()
}
+75
View File
@@ -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
}
+224
View File
@@ -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
}
+79
View File
@@ -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
}
+77
View File
@@ -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
}
+73
View File
@@ -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
}
+55
View File
@@ -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
}