feat: opaque session、安装/发帖 SSR 与最小 Admin 后台
浏览器登录改为 DB sessions(可吊销);敏感词与 OIDC PEM 入 settings; 落地安装向导、注册发帖与 /admin 仪表盘/板块/审核/设置。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
327
config/config.go
327
config/config.go
@@ -1,50 +1,78 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// StorageTypeLocal / StorageTypeS3 上传存储后端(管理后台运行时配置)
|
||||
const (
|
||||
defaultPort = 3000
|
||||
defaultDataRel = "data"
|
||||
|
||||
DBTypeSQLite = "sqlite"
|
||||
DBTypePostgres = "postgres"
|
||||
DBTypeMySQL = "mysql"
|
||||
|
||||
// StorageTypeLocal / StorageTypeS3 上传存储后端(管理后台运行时配置)
|
||||
StorageTypeLocal = "local"
|
||||
StorageTypeS3 = "s3"
|
||||
)
|
||||
|
||||
// Config 应用全局配置:默认读工作目录下 app.ini,命令行可覆盖
|
||||
type Config struct {
|
||||
// 工作目录(默认可执行文件所在目录)
|
||||
WorkPath string
|
||||
// 配置文件绝对路径
|
||||
ConfigFile string
|
||||
// 监听端口
|
||||
Port int
|
||||
// 数据目录:SQLite、上传、日志(绝对路径)
|
||||
DataDir string
|
||||
// JWT 签名密钥
|
||||
JWTSecret string
|
||||
// 日志文件路径
|
||||
LogFile string
|
||||
// 系统服务控制动作:install|uninstall|start|stop|restart|status,空表示正常运行
|
||||
ServiceAction string
|
||||
// 开发模式:后端代理前端请求到 Vite 开发服务器(非内嵌静态资源)
|
||||
DevMode bool
|
||||
// DatabaseConfig 数据库引导配置(需重启)
|
||||
type DatabaseConfig struct {
|
||||
Type string // sqlite | postgres | mysql
|
||||
DSN string // 非空则优先
|
||||
Host string
|
||||
User string
|
||||
Password string
|
||||
Name string
|
||||
SSLMode string // postgres
|
||||
// SQLite 文件路径(Type=sqlite 时由 DataDir 推导或 DSN)
|
||||
SQLitePath string
|
||||
|
||||
MaxOpenConns int
|
||||
MaxIdleConns int
|
||||
ConnMaxLifetimeSec int
|
||||
}
|
||||
|
||||
// Parse 解析命令行、环境变量与 app.ini,并初始化数据目录
|
||||
//
|
||||
// 优先级(高 → 低):命令行显式参数 > 环境变量 > app.ini > 内置默认值
|
||||
// Config 进程引导配置:仅 CLI / 环境变量(无 INI)
|
||||
type Config struct {
|
||||
WorkPath string
|
||||
HTTPAddr string // 空表示 0.0.0.0
|
||||
Port int
|
||||
DataDir string
|
||||
JWTSecret string
|
||||
LogFile string
|
||||
ServiceAction string
|
||||
DevMode bool
|
||||
DB DatabaseConfig
|
||||
}
|
||||
|
||||
// Parse 解析命令行与环境变量并准备数据目录
|
||||
// 优先级:命令行显式参数 > 环境变量 > 内置默认
|
||||
func Parse() (*Config, error) {
|
||||
configFlag := flag.String("config", "", "配置文件路径(默认:工作目录/app.ini)")
|
||||
workFlag := flag.String("work-path", "", "工作目录(默认:可执行文件所在目录)")
|
||||
portFlag := flag.Int("port", 0, "HTTP 监听端口(覆盖配置文件;0 表示不覆盖)")
|
||||
dataFlag := flag.String("data", "", "数据存储目录(覆盖配置文件)")
|
||||
jwtFlag := flag.String("jwt-secret", "", "JWT 签名密钥(覆盖配置文件;留空则自动生成)")
|
||||
portFlag := flag.Int("port", 0, "HTTP 监听端口(0 表示用环境变量或默认 3000)")
|
||||
addrFlag := flag.String("http-addr", "", "HTTP 监听地址(默认空=全接口)")
|
||||
dataFlag := flag.String("data", "", "数据存储目录")
|
||||
dbTypeFlag := flag.String("db-type", "", "数据库类型:sqlite|postgres|mysql")
|
||||
dbDSNFlag := flag.String("db-dsn", "", "数据库 DSN(优先于拆分参数)")
|
||||
dbHostFlag := flag.String("db-host", "", "数据库主机")
|
||||
dbUserFlag := flag.String("db-user", "", "数据库用户")
|
||||
dbPassFlag := flag.String("db-pass", "", "数据库密码")
|
||||
dbNameFlag := flag.String("db-name", "", "数据库名")
|
||||
dbSSLFlag := flag.String("db-sslmode", "", "PostgreSQL sslmode")
|
||||
serviceFlag := flag.String("service", "", "系统服务控制:install|uninstall|start|stop|restart|status")
|
||||
devFlag := flag.Bool("dev", false, "开发模式:代理前端到 Vite 开发服务器(默认 http://localhost:5173)")
|
||||
devFlag := flag.Bool("dev", false, "开发模式")
|
||||
_ = flag.String("config", "", "已废弃:不再使用 ini 配置文件")
|
||||
_ = flag.String("jwt-secret", "", "已废弃:JWT 仅使用 data/.jwt_secret")
|
||||
flag.Parse()
|
||||
|
||||
action := strings.ToLower(strings.TrimSpace(*serviceFlag))
|
||||
@@ -52,35 +80,13 @@ func Parse() (*Config, error) {
|
||||
return nil, fmt.Errorf("无效的 -service 动作 %q,可选:install|uninstall|start|stop|restart|status", *serviceFlag)
|
||||
}
|
||||
|
||||
workPathInput := strings.TrimSpace(*workFlag)
|
||||
if workPathInput == "" {
|
||||
workPathInput = envOrDefault(envWorkPath)
|
||||
}
|
||||
workPathInput := firstNonEmpty(*workFlag, envOrDefault(envWorkPath))
|
||||
workPath, err := resolveWorkPath(workPathInput)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
configInput := strings.TrimSpace(*configFlag)
|
||||
if configInput == "" {
|
||||
configInput = envOrDefault(envConfig)
|
||||
}
|
||||
configFile, err := resolveConfigPath(workPath, configInput)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fileCfg := defaultFileSettings()
|
||||
configExists := false
|
||||
if st, err := os.Stat(configFile); err == nil && !st.IsDir() {
|
||||
configExists = true
|
||||
fileCfg, err = loadAppINI(configFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
port := fileCfg.Port
|
||||
port := defaultPort
|
||||
if p := envIntOrZero(envHTTPPort); p > 0 {
|
||||
port = p
|
||||
}
|
||||
@@ -88,65 +94,35 @@ func Parse() (*Config, error) {
|
||||
port = *portFlag
|
||||
}
|
||||
|
||||
dataInput := fileCfg.DataRel
|
||||
if v := envOrDefault(envData); v != "" {
|
||||
dataInput = v
|
||||
}
|
||||
if strings.TrimSpace(*dataFlag) != "" {
|
||||
dataInput = *dataFlag
|
||||
}
|
||||
httpAddr := firstNonEmpty(*addrFlag, envOrDefault(envHTTPAddr))
|
||||
|
||||
dataInput := firstNonEmpty(*dataFlag, envOrDefault(envData), defaultDataRel)
|
||||
absData, err := absPath(workPath, dataInput)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析数据目录失败: %w", err)
|
||||
}
|
||||
|
||||
jwtSecret := fileCfg.JWTSecret
|
||||
if v := envOrDefault(envJWTSecret); v != "" {
|
||||
jwtSecret = v
|
||||
}
|
||||
if strings.TrimSpace(*jwtFlag) != "" {
|
||||
jwtSecret = strings.TrimSpace(*jwtFlag)
|
||||
dbCfg, err := buildDatabaseConfig(absData, dbFlags{
|
||||
Type: *dbTypeFlag, DSN: *dbDSNFlag, Host: *dbHostFlag,
|
||||
User: *dbUserFlag, Pass: *dbPassFlag, Name: *dbNameFlag, SSL: *dbSSLFlag,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg := &Config{
|
||||
WorkPath: workPath,
|
||||
ConfigFile: configFile,
|
||||
HTTPAddr: httpAddr,
|
||||
Port: port,
|
||||
DataDir: absData,
|
||||
JWTSecret: jwtSecret,
|
||||
LogFile: filepath.Join(absData, "jiang13.log"),
|
||||
ServiceAction: action,
|
||||
DevMode: *devFlag,
|
||||
DB: dbCfg,
|
||||
}
|
||||
|
||||
needDirs := action == "" || action == "install"
|
||||
if needDirs {
|
||||
// 首次启动自动生成 app.ini,便于像 Gitea 一样改文件而不记一长串参数
|
||||
if !configExists {
|
||||
dataRel := resolveDataRelForINI(workPath, absData)
|
||||
if err := writeAppINI(configFile, fileSettings{
|
||||
Port: port,
|
||||
DataRel: dataRel,
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("生成默认配置文件失败: %w", err)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "已生成默认配置: %s\n", configFile)
|
||||
} else if action == "install" {
|
||||
// 安装服务前把当前生效配置写回,避免服务只读旧 app.ini
|
||||
dataRel := resolveDataRelForINI(workPath, absData)
|
||||
iniJWT := fileCfg.JWTSecret
|
||||
if strings.TrimSpace(*jwtFlag) != "" {
|
||||
iniJWT = jwtSecret
|
||||
}
|
||||
if err := writeAppINI(configFile, fileSettings{
|
||||
Port: port,
|
||||
DataRel: dataRel,
|
||||
JWTSecret: iniJWT,
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("更新配置文件失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := ensureDataDirs(absData); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -158,6 +134,96 @@ func Parse() (*Config, error) {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
type dbFlags struct {
|
||||
Type, DSN, Host, User, Pass, Name, SSL string
|
||||
}
|
||||
|
||||
func buildDatabaseConfig(dataDir string, f dbFlags) (DatabaseConfig, error) {
|
||||
typ := strings.ToLower(firstNonEmpty(f.Type, envOrDefault(envDBType), DBTypeSQLite))
|
||||
switch typ {
|
||||
case "sqlite", "sqlite3":
|
||||
typ = DBTypeSQLite
|
||||
case "postgres", "postgresql", "pg":
|
||||
typ = DBTypePostgres
|
||||
case "mysql", "mariadb":
|
||||
typ = DBTypeMySQL
|
||||
default:
|
||||
return DatabaseConfig{}, fmt.Errorf("不支持的数据库类型 %q,可选:sqlite|postgres|mysql", typ)
|
||||
}
|
||||
|
||||
out := DatabaseConfig{
|
||||
Type: typ,
|
||||
DSN: firstNonEmpty(f.DSN, envOrDefault(envDBDSN)),
|
||||
Host: firstNonEmpty(f.Host, envOrDefault(envDBHost)),
|
||||
User: firstNonEmpty(f.User, envOrDefault(envDBUser)),
|
||||
Password: firstNonEmpty(f.Pass, envOrDefault(envDBPass)),
|
||||
Name: firstNonEmpty(f.Name, envOrDefault(envDBName)),
|
||||
SSLMode: firstNonEmpty(f.SSL, envOrDefault(envDBSSLMode), "disable"),
|
||||
MaxOpenConns: envIntDefault(envDBMaxOpen, 0),
|
||||
MaxIdleConns: envIntDefault(envDBMaxIdle, 0),
|
||||
ConnMaxLifetimeSec: envIntDefault(envDBConnLife, 0),
|
||||
}
|
||||
|
||||
if typ == DBTypeSQLite {
|
||||
if out.DSN != "" {
|
||||
out.SQLitePath = out.DSN
|
||||
} else {
|
||||
out.SQLitePath = filepath.Join(dataDir, "jiang13.db")
|
||||
out.DSN = out.SQLitePath
|
||||
}
|
||||
if out.MaxOpenConns == 0 {
|
||||
out.MaxOpenConns = 1
|
||||
}
|
||||
if out.MaxIdleConns == 0 {
|
||||
out.MaxIdleConns = 1
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
if out.DSN == "" {
|
||||
dsn, err := buildDSN(out)
|
||||
if err != nil {
|
||||
return DatabaseConfig{}, err
|
||||
}
|
||||
out.DSN = dsn
|
||||
}
|
||||
if out.MaxOpenConns == 0 {
|
||||
out.MaxOpenConns = 25
|
||||
}
|
||||
if out.MaxIdleConns == 0 {
|
||||
out.MaxIdleConns = 5
|
||||
}
|
||||
if out.ConnMaxLifetimeSec == 0 {
|
||||
out.ConnMaxLifetimeSec = 300
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func buildDSN(c DatabaseConfig) (string, error) {
|
||||
if c.Host == "" || c.User == "" || c.Name == "" {
|
||||
return "", fmt.Errorf("%s 需要 JIANG13_DB_DSN,或 JIANG13_DB_HOST/USER/NAME(及可选 PASS)", c.Type)
|
||||
}
|
||||
switch c.Type {
|
||||
case DBTypePostgres:
|
||||
u := url.URL{
|
||||
Scheme: "postgres",
|
||||
User: url.UserPassword(c.User, c.Password),
|
||||
Host: c.Host,
|
||||
Path: "/" + c.Name,
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("sslmode", c.SSLMode)
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String(), nil
|
||||
case DBTypeMySQL:
|
||||
// 特殊字符密码请直接用 JIANG13_DB_DSN;此处为拆分参数简易拼接
|
||||
return fmt.Sprintf("%s:%s@tcp(%s)/%s?parseTime=true&loc=Local&charset=utf8mb4",
|
||||
c.User, c.Password, c.Host, c.Name), nil
|
||||
default:
|
||||
return "", fmt.Errorf("无法为 %s 拼接 DSN", c.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func resolveWorkPath(flagVal string) (string, error) {
|
||||
if strings.TrimSpace(flagVal) != "" {
|
||||
abs, err := filepath.Abs(flagVal)
|
||||
@@ -169,13 +235,6 @@ func resolveWorkPath(flagVal string) (string, error) {
|
||||
return defaultWorkPath()
|
||||
}
|
||||
|
||||
func resolveConfigPath(workPath, flagVal string) (string, error) {
|
||||
if strings.TrimSpace(flagVal) != "" {
|
||||
return absPath(workPath, flagVal)
|
||||
}
|
||||
return filepath.Join(workPath, defaultConfName), nil
|
||||
}
|
||||
|
||||
func ensureDataDirs(dataDir string) error {
|
||||
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
||||
return fmt.Errorf("创建数据目录失败: %w", err)
|
||||
@@ -194,21 +253,25 @@ func ensureDataDirs(dataDir string) error {
|
||||
|
||||
func (c *Config) resolveJWT() error {
|
||||
secretFile := filepath.Join(c.DataDir, ".jwt_secret")
|
||||
if c.JWTSecret != "" {
|
||||
_ = os.WriteFile(secretFile, []byte(c.JWTSecret), 0600)
|
||||
if data, err := os.ReadFile(secretFile); err == nil && len(bytesTrimSpace(data)) > 0 {
|
||||
c.JWTSecret = string(bytesTrimSpace(data))
|
||||
return nil
|
||||
}
|
||||
if data, err := os.ReadFile(secretFile); err == nil && len(data) > 0 {
|
||||
c.JWTSecret = string(data)
|
||||
return nil
|
||||
sec, err := generateRandomSecret(32)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.JWTSecret = generateRandomSecret(32)
|
||||
c.JWTSecret = sec
|
||||
if err := os.WriteFile(secretFile, []byte(c.JWTSecret), 0600); err != nil {
|
||||
return fmt.Errorf("写入 JWT 密钥失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func bytesTrimSpace(b []byte) []byte {
|
||||
return []byte(strings.TrimSpace(string(b)))
|
||||
}
|
||||
|
||||
func validServiceAction(action string) bool {
|
||||
switch action {
|
||||
case "install", "uninstall", "start", "stop", "restart", "status":
|
||||
@@ -218,36 +281,60 @@ func validServiceAction(action string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// DBPath 返回 SQLite 数据库文件路径
|
||||
func (c *Config) DBPath() string {
|
||||
return filepath.Join(c.DataDir, "jiang13.db")
|
||||
// ListenAddr 返回 host:port
|
||||
func (c *Config) ListenAddr() string {
|
||||
if c.HTTPAddr == "" {
|
||||
return fmt.Sprintf(":%d", c.Port)
|
||||
}
|
||||
return fmt.Sprintf("%s:%d", c.HTTPAddr, c.Port)
|
||||
}
|
||||
|
||||
// SQLitePath 兼容旧调用:仅 sqlite 有意义
|
||||
func (c *Config) DBPath() string {
|
||||
if c.DB.Type == DBTypeSQLite {
|
||||
return c.DB.SQLitePath
|
||||
}
|
||||
return c.DB.DSN
|
||||
}
|
||||
|
||||
// AvatarUploadDir 返回头像上传目录
|
||||
func (c *Config) AvatarUploadDir() string {
|
||||
return filepath.Join(c.DataDir, "uploads", "avatars")
|
||||
}
|
||||
|
||||
// PostImageUploadDir 返回帖子正文图片上传目录
|
||||
func (c *Config) PostImageUploadDir() string {
|
||||
return filepath.Join(c.DataDir, "uploads", "posts")
|
||||
}
|
||||
|
||||
// SiteUploadDir 返回站点品牌资源(Logo / Favicon)目录
|
||||
func (c *Config) SiteUploadDir() string {
|
||||
return filepath.Join(c.DataDir, "uploads", "site")
|
||||
}
|
||||
|
||||
// FilterWordsPath 返回敏感词配置文件路径
|
||||
func (c *Config) FilterWordsPath() string {
|
||||
return filepath.Join(c.DataDir, "filter_words.txt")
|
||||
}
|
||||
|
||||
func generateRandomSecret(n int) string {
|
||||
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
func generateRandomSecret(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
b[i] = chars[i%len(chars)]
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("生成密钥失败: %w", err)
|
||||
}
|
||||
return string(b)
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func envIntDefault(key string, def int) int {
|
||||
v := envOrDefault(key)
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil || n < 0 {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user