up
This commit is contained in:
@@ -18,6 +18,9 @@ portal
|
|||||||
Thumbs.db
|
Thumbs.db
|
||||||
Desktop.ini
|
Desktop.ini
|
||||||
|
|
||||||
|
# 配置文件(运行时自动生成)
|
||||||
|
/conf/
|
||||||
|
|
||||||
# 数据库
|
# 数据库
|
||||||
/data/*.db
|
/data/*.db
|
||||||
/data/*.db-shm
|
/data/*.db-shm
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
|
||||||
|
"github.com/pelletier/go-toml/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Cfg 是全局配置实例,在 Load() 后可用
|
||||||
|
var Cfg *Config
|
||||||
|
|
||||||
|
// Config 是完整的配置结构
|
||||||
|
type Config struct {
|
||||||
|
Data DataConfig `toml:"data"`
|
||||||
|
Database DatabaseConfig `toml:"database"`
|
||||||
|
Server ServerConfig `toml:"server"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DataConfig 数据存储配置
|
||||||
|
type DataConfig struct {
|
||||||
|
// 数据存储根目录,Windows 默认 "data",Linux 默认 "/srv/portal_page"
|
||||||
|
Dir string `toml:"dir"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DatabaseConfig 数据库配置
|
||||||
|
type DatabaseConfig struct {
|
||||||
|
// 数据库类型: "sqlite" 或 "mysql"
|
||||||
|
Type string `toml:"type"`
|
||||||
|
// SQLite 数据库文件名(相对于 data.dir)
|
||||||
|
Path string `toml:"path"`
|
||||||
|
// MySQL 配置
|
||||||
|
MySQL MySQLConfig `toml:"mysql"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MySQLConfig MySQL 连接配置
|
||||||
|
type MySQLConfig struct {
|
||||||
|
Host string `toml:"host"`
|
||||||
|
Port int `toml:"port"`
|
||||||
|
User string `toml:"user"`
|
||||||
|
Password string `toml:"password"`
|
||||||
|
DBName string `toml:"dbname"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServerConfig Web 服务器配置
|
||||||
|
type ServerConfig struct {
|
||||||
|
// 监听地址,格式 ":8080"
|
||||||
|
Addr string `toml:"addr"`
|
||||||
|
// Unix socket 路径(设置后优先使用 unix socket,addr 被忽略)
|
||||||
|
Unix string `toml:"unix"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultConfig 返回当前平台的默认配置
|
||||||
|
func defaultConfig() *Config {
|
||||||
|
cfg := &Config{
|
||||||
|
Data: DataConfig{
|
||||||
|
Dir: "data",
|
||||||
|
},
|
||||||
|
Database: DatabaseConfig{
|
||||||
|
Type: "sqlite",
|
||||||
|
Path: "portal.db",
|
||||||
|
MySQL: MySQLConfig{
|
||||||
|
Host: "127.0.0.1",
|
||||||
|
Port: 3306,
|
||||||
|
User: "root",
|
||||||
|
Password: "",
|
||||||
|
DBName: "portal_page",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Server: ServerConfig{
|
||||||
|
Addr: ":8080",
|
||||||
|
Unix: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Linux 下遵循 FHS 标准
|
||||||
|
if runtime.GOOS == "linux" {
|
||||||
|
cfg.Data.Dir = "/srv/portal_page"
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// configPath 返回当前平台的配置文件路径
|
||||||
|
func configPath() string {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
// Windows: 相对于可执行文件的 conf/config.toml
|
||||||
|
exePath, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return "conf/config.toml"
|
||||||
|
}
|
||||||
|
return filepath.Join(filepath.Dir(exePath), "conf", "config.toml")
|
||||||
|
}
|
||||||
|
// Linux: FHS 标准 /etc/portal_page/config.toml
|
||||||
|
return "/etc/portal_page/config.toml"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load 加载配置文件,不存在则自动生成,缺失项自动补全
|
||||||
|
func Load() error {
|
||||||
|
path := configPath()
|
||||||
|
def := defaultConfig()
|
||||||
|
|
||||||
|
// 如果配置文件不存在,使用默认配置直接生成
|
||||||
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||||
|
log.Printf("配置文件不存在,自动生成: %s", path)
|
||||||
|
Cfg = def
|
||||||
|
return saveConfig(path, Cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取并解析配置文件
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("读取配置文件失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
Cfg = &Config{}
|
||||||
|
if err := toml.Unmarshal(data, Cfg); err != nil {
|
||||||
|
return fmt.Errorf("解析配置文件失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 补全缺失项
|
||||||
|
changed := fillDefaults(Cfg, def)
|
||||||
|
if changed {
|
||||||
|
log.Printf("配置文件有缺失项,已自动补全: %s", path)
|
||||||
|
if err := saveConfig(path, Cfg); err != nil {
|
||||||
|
return fmt.Errorf("保存补全后的配置文件失败: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fillDefaults 用默认值补全零值字段,返回是否有变更
|
||||||
|
func fillDefaults(cfg, def *Config) bool {
|
||||||
|
changed := false
|
||||||
|
|
||||||
|
// Data
|
||||||
|
if cfg.Data.Dir == "" {
|
||||||
|
cfg.Data.Dir = def.Data.Dir
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Database
|
||||||
|
if cfg.Database.Type == "" {
|
||||||
|
cfg.Database.Type = def.Database.Type
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if cfg.Database.Path == "" {
|
||||||
|
cfg.Database.Path = def.Database.Path
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if cfg.Database.MySQL.Host == "" {
|
||||||
|
cfg.Database.MySQL.Host = def.Database.MySQL.Host
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if cfg.Database.MySQL.Port == 0 {
|
||||||
|
cfg.Database.MySQL.Port = def.Database.MySQL.Port
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if cfg.Database.MySQL.User == "" {
|
||||||
|
cfg.Database.MySQL.User = def.Database.MySQL.User
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
// Password 允许为空字符串,不补全
|
||||||
|
if cfg.Database.MySQL.DBName == "" {
|
||||||
|
cfg.Database.MySQL.DBName = def.Database.MySQL.DBName
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server
|
||||||
|
if cfg.Server.Addr == "" && cfg.Server.Unix == "" {
|
||||||
|
cfg.Server.Addr = def.Server.Addr
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveConfig 将配置写入文件
|
||||||
|
func saveConfig(path string, cfg *Config) error {
|
||||||
|
// 确保目录存在
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("创建配置目录失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := toml.Marshal(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("序列化配置失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(path, data, 0644); err != nil {
|
||||||
|
return fmt.Errorf("写入配置文件失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUploadDir 返回上传文件目录的完整路径
|
||||||
|
func GetUploadDir() string {
|
||||||
|
return filepath.Join(Cfg.Data.Dir, "uploads")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDBPath 返回 SQLite 数据库文件的完整路径
|
||||||
|
func GetDBPath() string {
|
||||||
|
return filepath.Join(Cfg.Data.Dir, Cfg.Database.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDSN 返回 MySQL 的 DSN 连接字符串
|
||||||
|
func (m *MySQLConfig) GetDSN() string {
|
||||||
|
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true&loc=Local",
|
||||||
|
m.User, m.Password, m.Host, m.Port, m.DBName)
|
||||||
|
}
|
||||||
+67
-19
@@ -6,50 +6,98 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
|
"simple_portal/config"
|
||||||
|
|
||||||
|
_ "github.com/go-sql-driver/mysql"
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DB is the global database connection pointer.
|
// DB is the global database connection pointer.
|
||||||
var DB *sql.DB
|
var DB *sql.DB
|
||||||
|
|
||||||
// InitDB initializes the SQLite database, creates the data directory,
|
// InitDB 根据配置初始化数据库,支持 SQLite 和 MySQL
|
||||||
// opens the connection, sets WAL mode, and creates default tables and data.
|
|
||||||
func InitDB() error {
|
func InitDB() error {
|
||||||
dbPath := filepath.Join(".", "data", "portal.db")
|
dbType := config.Cfg.Database.Type
|
||||||
|
dataDir := config.Cfg.Data.Dir
|
||||||
|
|
||||||
// Create data directory if not exists
|
switch dbType {
|
||||||
|
case "mysql":
|
||||||
|
return initMySQL()
|
||||||
|
case "sqlite":
|
||||||
|
return initSQLite(dataDir)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("不支持的数据库类型: %s(可选: sqlite, mysql)", dbType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// initSQLite 初始化 SQLite 数据库
|
||||||
|
func initSQLite(dataDir string) error {
|
||||||
|
dbPath := config.GetDBPath()
|
||||||
|
|
||||||
|
// 创建数据目录
|
||||||
if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil {
|
||||||
return fmt.Errorf("failed to create data directory: %w", err)
|
return fmt.Errorf("创建数据目录失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
DB, err = sql.Open("sqlite", dbPath)
|
DB, err = sql.Open("sqlite", dbPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to open database: %w", err)
|
return fmt.Errorf("打开 SQLite 数据库失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SQLite requires max open conns = 1 for safe writes
|
// SQLite 需要限制最大连接数为 1 以保证写安全
|
||||||
DB.SetMaxOpenConns(1)
|
DB.SetMaxOpenConns(1)
|
||||||
|
|
||||||
// Enable WAL mode for better concurrent read performance
|
// 启用 WAL 模式提升并发读性能
|
||||||
if _, err := DB.Exec("PRAGMA journal_mode=WAL"); err != nil {
|
if _, err := DB.Exec("PRAGMA journal_mode=WAL"); err != nil {
|
||||||
return fmt.Errorf("failed to set WAL mode: %w", err)
|
return fmt.Errorf("设置 WAL 模式失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create tables
|
// 创建表
|
||||||
if err := createTables(); err != nil {
|
if err := createTables(); err != nil {
|
||||||
return fmt.Errorf("failed to create tables: %w", err)
|
return fmt.Errorf("创建数据表失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Seed default data
|
// 初始化默认数据
|
||||||
if err := seedData(); err != nil {
|
if err := seedData(); err != nil {
|
||||||
return fmt.Errorf("failed to seed data: %w", err)
|
return fmt.Errorf("初始化默认数据失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// createTables creates the cards, settings, and admins tables.
|
// initMySQL 初始化 MySQL 数据库
|
||||||
|
func initMySQL() error {
|
||||||
|
dsn := config.Cfg.Database.MySQL.GetDSN()
|
||||||
|
|
||||||
|
var err error
|
||||||
|
DB, err = sql.Open("mysql", dsn)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("打开 MySQL 数据库失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试连接
|
||||||
|
if err := DB.Ping(); err != nil {
|
||||||
|
return fmt.Errorf("连接 MySQL 失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MySQL 不限制最大连接数(使用默认池)
|
||||||
|
DB.SetMaxOpenConns(0)
|
||||||
|
|
||||||
|
// 创建表
|
||||||
|
if err := createTables(); err != nil {
|
||||||
|
return fmt.Errorf("创建数据表失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化默认数据
|
||||||
|
if err := seedData(); err != nil {
|
||||||
|
return fmt.Errorf("初始化默认数据失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createTables 创建所有数据表
|
||||||
func createTables() error {
|
func createTables() error {
|
||||||
_, err := DB.Exec(`
|
_, err := DB.Exec(`
|
||||||
CREATE TABLE IF NOT EXISTS cards (
|
CREATE TABLE IF NOT EXISTS cards (
|
||||||
@@ -110,9 +158,9 @@ func createTables() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// seedData inserts default admin account and search engine setting if not present.
|
// seedData 插入默认管理员和搜索引擎配置
|
||||||
func seedData() error {
|
func seedData() error {
|
||||||
// Insert default admin if not exists
|
// 插入默认管理员
|
||||||
var count int
|
var count int
|
||||||
err := DB.QueryRow("SELECT COUNT(*) FROM admins WHERE username = ?", "admin").Scan(&count)
|
err := DB.QueryRow("SELECT COUNT(*) FROM admins WHERE username = ?", "admin").Scan(&count)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -129,7 +177,7 @@ func seedData() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert default search engine setting if not exists
|
// 插入默认搜索引擎设置
|
||||||
var settingCount int
|
var settingCount int
|
||||||
err = DB.QueryRow("SELECT COUNT(*) FROM settings WHERE key = ?", "search_engine").Scan(&settingCount)
|
err = DB.QueryRow("SELECT COUNT(*) FROM settings WHERE key = ?", "search_engine").Scan(&settingCount)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -146,7 +194,7 @@ func seedData() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert default homepage settings if not exists
|
// 插入默认主页设置
|
||||||
defaultSettings := []struct {
|
defaultSettings := []struct {
|
||||||
key string
|
key string
|
||||||
value string
|
value string
|
||||||
@@ -172,7 +220,7 @@ func seedData() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CloseDB closes the database connection.
|
// CloseDB 关闭数据库连接
|
||||||
func CloseDB() error {
|
func CloseDB() error {
|
||||||
if DB != nil {
|
if DB != nil {
|
||||||
return DB.Close()
|
return DB.Close()
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
module simple_portal
|
module simple_portal
|
||||||
|
|
||||||
go 1.22
|
go 1.24.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/disintegration/imaging v1.6.2
|
github.com/disintegration/imaging v1.6.2
|
||||||
github.com/gin-gonic/gin v1.10.0
|
github.com/gin-gonic/gin v1.10.0
|
||||||
|
github.com/go-sql-driver/mysql v1.10.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.2
|
||||||
golang.org/x/crypto v0.28.0
|
golang.org/x/crypto v0.28.0
|
||||||
modernc.org/sqlite v1.34.5
|
modernc.org/sqlite v1.34.5
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
filippo.io/edwards25519 v1.2.0 // indirect
|
||||||
github.com/bytedance/sonic v1.11.6 // indirect
|
github.com/bytedance/sonic v1.11.6 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||||
@@ -29,7 +32,6 @@ require (
|
|||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/ncruces/go-strftime v0.1.9 // 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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||||
|
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
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 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 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||||
@@ -27,6 +29,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
|
|||||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
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 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
||||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||||
|
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
|
||||||
|
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
|
||||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
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/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 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||||
|
|||||||
+7
-8
@@ -9,6 +9,8 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"simple_portal/config"
|
||||||
|
|
||||||
"github.com/disintegration/imaging"
|
"github.com/disintegration/imaging"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -24,9 +26,6 @@ var allowedMIMETypes = map[string]string{
|
|||||||
// maxUploadSize is the maximum allowed file size in bytes (5 MB).
|
// maxUploadSize is the maximum allowed file size in bytes (5 MB).
|
||||||
const maxUploadSize = 5 << 20
|
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.
|
// thumbSuffix is the suffix appended to compressed image filenames.
|
||||||
const thumbSuffix = "_thumb"
|
const thumbSuffix = "_thumb"
|
||||||
|
|
||||||
@@ -94,13 +93,13 @@ func UploadHandler(c *gin.Context) {
|
|||||||
filename := fileUUID + ext
|
filename := fileUUID + ext
|
||||||
|
|
||||||
// Ensure upload directory exists
|
// Ensure upload directory exists
|
||||||
if err := os.MkdirAll(uploadDir, 0755); err != nil {
|
if err := os.MkdirAll(config.GetUploadDir(), 0755); err != nil {
|
||||||
c.JSON(500, gin.H{"error": "创建上传目录失败"})
|
c.JSON(500, gin.H{"error": "创建上传目录失败"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save original file
|
// Save original file
|
||||||
originalPath := filepath.Join(uploadDir, filename)
|
originalPath := filepath.Join(config.GetUploadDir(), filename)
|
||||||
dst, err := os.Create(originalPath)
|
dst, err := os.Create(originalPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(500, gin.H{"error": "保存文件失败"})
|
c.JSON(500, gin.H{"error": "保存文件失败"})
|
||||||
@@ -142,14 +141,14 @@ func ServeUploadHandler(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
filePath := filepath.Join(uploadDir, filename)
|
filePath := filepath.Join(config.GetUploadDir(), filename)
|
||||||
|
|
||||||
// Check if thumb=1 query parameter is requested
|
// Check if thumb=1 query parameter is requested
|
||||||
if c.Query("thumb") == "1" {
|
if c.Query("thumb") == "1" {
|
||||||
// Try to serve the thumbnail version
|
// Try to serve the thumbnail version
|
||||||
ext := filepath.Ext(filename)
|
ext := filepath.Ext(filename)
|
||||||
baseName := filename[:len(filename)-len(ext)]
|
baseName := filename[:len(filename)-len(ext)]
|
||||||
thumbPath := filepath.Join(uploadDir, baseName+thumbSuffix+".jpg")
|
thumbPath := filepath.Join(config.GetUploadDir(), baseName+thumbSuffix+".jpg")
|
||||||
|
|
||||||
if _, err := os.Stat(thumbPath); err == nil {
|
if _, err := os.Stat(thumbPath); err == nil {
|
||||||
c.File(thumbPath)
|
c.File(thumbPath)
|
||||||
@@ -221,7 +220,7 @@ func generateThumbnail(originalPath, fileUUID, uploadType string) error {
|
|||||||
thumb := fitImage(img, maxWidth, maxHeight)
|
thumb := fitImage(img, maxWidth, maxHeight)
|
||||||
|
|
||||||
// Save thumbnail as JPEG
|
// Save thumbnail as JPEG
|
||||||
thumbPath := filepath.Join(uploadDir, fileUUID+thumbSuffix+".jpg")
|
thumbPath := filepath.Join(config.GetUploadDir(), fileUUID+thumbSuffix+".jpg")
|
||||||
thumbFile, err := os.Create(thumbPath)
|
thumbFile, err := os.Create(thumbPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create thumbnail file: %w", err)
|
return fmt.Errorf("failed to create thumbnail file: %w", err)
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ package main
|
|||||||
import (
|
import (
|
||||||
"html/template"
|
"html/template"
|
||||||
"log"
|
"log"
|
||||||
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"simple_portal/config"
|
||||||
"simple_portal/database"
|
"simple_portal/database"
|
||||||
"simple_portal/handlers"
|
"simple_portal/handlers"
|
||||||
"simple_portal/middleware"
|
"simple_portal/middleware"
|
||||||
@@ -15,8 +17,8 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// loadTemplates loads HTML templates from templates/ directory recursively.
|
// loadTemplates 加载 templates/ 目录下所有 HTML 模板
|
||||||
// Custom implementation because Go's ParseGlob has issues with directories on Windows.
|
// 自定义实现,因为 Go 的 ParseGlob 在 Windows 下有路径问题
|
||||||
func loadTemplates() *template.Template {
|
func loadTemplates() *template.Template {
|
||||||
funcMap := template.FuncMap{
|
funcMap := template.FuncMap{
|
||||||
"hasPrefix": strings.HasPrefix,
|
"hasPrefix": strings.HasPrefix,
|
||||||
@@ -24,7 +26,6 @@ func loadTemplates() *template.Template {
|
|||||||
"add": func(a, b int) int { return a + b },
|
"add": func(a, b int) int { return a + b },
|
||||||
}
|
}
|
||||||
t := template.New("").Funcs(funcMap)
|
t := template.New("").Funcs(funcMap)
|
||||||
// 收集所有 .html 模板文件路径
|
|
||||||
var files []string
|
var files []string
|
||||||
filepath.Walk("templates", func(path string, info os.FileInfo, err error) error {
|
filepath.Walk("templates", func(path string, info os.FileInfo, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -38,7 +39,6 @@ func loadTemplates() *template.Template {
|
|||||||
if len(files) == 0 {
|
if len(files) == 0 {
|
||||||
log.Fatal("No template files found in templates/")
|
log.Fatal("No template files found in templates/")
|
||||||
}
|
}
|
||||||
// 将 Windows 反斜杠路径转为正斜杠,避免模板名问题
|
|
||||||
for i, f := range files {
|
for i, f := range files {
|
||||||
files[i] = filepath.ToSlash(f)
|
files[i] = filepath.ToSlash(f)
|
||||||
}
|
}
|
||||||
@@ -51,24 +51,30 @@ func loadTemplates() *template.Template {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Initialize database
|
// 加载配置文件(自动生成 + 补全缺失项)
|
||||||
|
if err := config.Load(); err != nil {
|
||||||
|
log.Fatalf("加载配置失败: %v", err)
|
||||||
|
}
|
||||||
|
log.Printf("配置加载成功,数据目录: %s,数据库: %s", config.Cfg.Data.Dir, config.Cfg.Database.Type)
|
||||||
|
|
||||||
|
// 初始化数据库
|
||||||
if err := database.InitDB(); err != nil {
|
if err := database.InitDB(); err != nil {
|
||||||
log.Fatalf("Failed to initialize database: %v", err)
|
log.Fatalf("初始化数据库失败: %v", err)
|
||||||
}
|
}
|
||||||
defer database.CloseDB()
|
defer database.CloseDB()
|
||||||
|
|
||||||
// Create uploads directory
|
// 创建上传目录
|
||||||
if err := os.MkdirAll(filepath.Join(".", "data", "uploads"), 0755); err != nil {
|
if err := os.MkdirAll(config.GetUploadDir(), 0755); err != nil {
|
||||||
log.Fatalf("Failed to create uploads directory: %v", err)
|
log.Fatalf("创建上传目录失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create session store
|
// 创建 session 存储
|
||||||
sessionStore := session.NewSessionStore()
|
sessionStore := session.NewSessionStore()
|
||||||
|
|
||||||
// Create IP ban guard (in-memory fail counter)
|
// 创建 IP 封禁守护
|
||||||
ipBanGuard := middleware.NewIPBanGuard()
|
ipBanGuard := middleware.NewIPBanGuard()
|
||||||
|
|
||||||
// Set Gin mode
|
// 设置 Gin 模式
|
||||||
ginMode := os.Getenv("GIN_MODE")
|
ginMode := os.Getenv("GIN_MODE")
|
||||||
if ginMode == "" {
|
if ginMode == "" {
|
||||||
gin.SetMode(gin.DebugMode)
|
gin.SetMode(gin.DebugMode)
|
||||||
@@ -76,43 +82,43 @@ func main() {
|
|||||||
|
|
||||||
r := gin.Default()
|
r := gin.Default()
|
||||||
|
|
||||||
// Load HTML templates (custom loader for nested directories)
|
// 加载 HTML 模板
|
||||||
r.SetHTMLTemplate(loadTemplates())
|
r.SetHTMLTemplate(loadTemplates())
|
||||||
|
|
||||||
// Serve static files
|
// 静态文件
|
||||||
r.Static("/static", "./static")
|
r.Static("/static", "./static")
|
||||||
|
|
||||||
// Inject session store and IP ban guard into context for handlers
|
// 注入 session 和 IP 封禁守护
|
||||||
r.Use(func(c *gin.Context) {
|
r.Use(func(c *gin.Context) {
|
||||||
c.Set("sessionStore", sessionStore)
|
c.Set("sessionStore", sessionStore)
|
||||||
c.Set("ipBanGuard", ipBanGuard)
|
c.Set("ipBanGuard", ipBanGuard)
|
||||||
c.Next()
|
c.Next()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Public routes (home page and uploads — no IP restriction)
|
// 公开路由
|
||||||
r.GET("/", handlers.HomeHandler)
|
r.GET("/", handlers.HomeHandler)
|
||||||
r.GET("/click/:id", handlers.CardClickHandler)
|
r.GET("/click/:id", handlers.CardClickHandler)
|
||||||
r.GET("/search", handlers.SearchHandler)
|
r.GET("/search", handlers.SearchHandler)
|
||||||
r.GET("/uploads/:filename", handlers.ServeUploadHandler)
|
r.GET("/uploads/:filename", handlers.ServeUploadHandler)
|
||||||
|
|
||||||
// Admin routes with IP whitelist check applied to all /admin/* routes
|
// 后台路由(IP 白名单)
|
||||||
adminGroup := r.Group("/admin")
|
adminGroup := r.Group("/admin")
|
||||||
adminGroup.Use(middleware.IPWhitelistRequired(func(sessionID string) bool {
|
adminGroup.Use(middleware.IPWhitelistRequired(func(sessionID string) bool {
|
||||||
return sessionStore.Get(sessionID) != nil
|
return sessionStore.Get(sessionID) != nil
|
||||||
}))
|
}))
|
||||||
{
|
{
|
||||||
// Public admin routes (login — no auth required, but IP whitelist applies)
|
// 登录(无需认证,受 IP 白名单限制)
|
||||||
adminGroup.GET("/login", handlers.LoginGet)
|
adminGroup.GET("/login", handlers.LoginGet)
|
||||||
adminGroup.POST("/login", handlers.LoginPost)
|
adminGroup.POST("/login", handlers.LoginPost)
|
||||||
|
|
||||||
// Protected admin routes (auth required)
|
// 需要认证的后台路由
|
||||||
protected := adminGroup.Group("")
|
protected := adminGroup.Group("")
|
||||||
protected.Use(middleware.AuthRequired(sessionStore))
|
protected.Use(middleware.AuthRequired(sessionStore))
|
||||||
{
|
{
|
||||||
protected.POST("/logout", handlers.Logout)
|
protected.POST("/logout", handlers.Logout)
|
||||||
protected.GET("/", handlers.AdminIndex)
|
protected.GET("/", handlers.AdminIndex)
|
||||||
|
|
||||||
// Cards management
|
// 卡片管理
|
||||||
protected.GET("/cards", handlers.CardsList)
|
protected.GET("/cards", handlers.CardsList)
|
||||||
protected.GET("/cards/new", handlers.CardCreateGet)
|
protected.GET("/cards/new", handlers.CardCreateGet)
|
||||||
protected.POST("/cards", handlers.CardCreatePost)
|
protected.POST("/cards", handlers.CardCreatePost)
|
||||||
@@ -123,39 +129,50 @@ func main() {
|
|||||||
protected.POST("/cards/:id/move-up", handlers.CardMoveUp)
|
protected.POST("/cards/:id/move-up", handlers.CardMoveUp)
|
||||||
protected.POST("/cards/:id/move-down", handlers.CardMoveDown)
|
protected.POST("/cards/:id/move-down", handlers.CardMoveDown)
|
||||||
|
|
||||||
// Image upload
|
// 图片上传
|
||||||
protected.POST("/upload", handlers.UploadHandler)
|
protected.POST("/upload", handlers.UploadHandler)
|
||||||
|
|
||||||
// Settings
|
// 设置
|
||||||
protected.GET("/settings", handlers.SettingsGet)
|
protected.GET("/settings", handlers.SettingsGet)
|
||||||
protected.POST("/settings", handlers.SettingsPost)
|
protected.POST("/settings", handlers.SettingsPost)
|
||||||
|
|
||||||
// Security: login logs
|
// 安全:登录日志
|
||||||
protected.GET("/logs", handlers.LoginLogsGet)
|
protected.GET("/logs", handlers.LoginLogsGet)
|
||||||
protected.POST("/logs/unban/:id", handlers.UnbanIP)
|
protected.POST("/logs/unban/:id", handlers.UnbanIP)
|
||||||
|
|
||||||
// Security: change password
|
// 安全:修改密码
|
||||||
protected.GET("/password", handlers.ChangePasswordGet)
|
protected.GET("/password", handlers.ChangePasswordGet)
|
||||||
protected.POST("/password", handlers.ChangePasswordPost)
|
protected.POST("/password", handlers.ChangePasswordPost)
|
||||||
|
|
||||||
// Security: IP whitelist management
|
// 安全:IP 白名单
|
||||||
protected.GET("/ip-whitelist", handlers.IPWhitelistGet)
|
protected.GET("/ip-whitelist", handlers.IPWhitelistGet)
|
||||||
protected.POST("/ip-whitelist/add", handlers.IPWhitelistAdd)
|
protected.POST("/ip-whitelist/add", handlers.IPWhitelistAdd)
|
||||||
protected.POST("/ip-whitelist/:id/delete", handlers.IPWhitelistDelete)
|
protected.POST("/ip-whitelist/:id/delete", handlers.IPWhitelistDelete)
|
||||||
|
|
||||||
// Analytics: access logs
|
// 分析:访问日志
|
||||||
protected.GET("/access-logs", handlers.AccessLogsGet)
|
protected.GET("/access-logs", handlers.AccessLogsGet)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine port
|
// 启动服务器
|
||||||
port := os.Getenv("PORT")
|
if config.Cfg.Server.Unix != "" {
|
||||||
if port == "" {
|
// Unix socket 模式
|
||||||
port = "8080"
|
listener, err := net.Listen("unix", config.Cfg.Server.Unix)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("监听 Unix socket 失败: %v", err)
|
||||||
|
}
|
||||||
|
// 设置 socket 文件权限,允许 nginx 等其他进程访问
|
||||||
|
os.Chmod(config.Cfg.Server.Unix, 0666)
|
||||||
|
log.Printf("启动 Portal 服务器,监听 Unix socket: %s", config.Cfg.Server.Unix)
|
||||||
|
if err := r.RunListener(listener); err != nil {
|
||||||
|
log.Fatalf("服务器启动失败: %v", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// TCP 模式
|
||||||
|
addr := config.Cfg.Server.Addr
|
||||||
|
log.Printf("启动 Portal 服务器,监听: %s", addr)
|
||||||
|
if err := r.Run(addr); err != nil {
|
||||||
|
log.Fatalf("服务器启动失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("Starting Portal server on :%s", port)
|
|
||||||
if err := r.Run(":" + port); err != nil {
|
|
||||||
log.Fatalf("Failed to start server: %v", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user