Files
jiang13-forum/config/config.go
freefire 822eef96be 新增 OIDC/SSO、邮件验证码与 Gitea 项目同步,并强化 Feed 与管理后台。
作为 OIDC Provider 对接 Gitea;注册支持邮件验证码/验证码;侧栏同步公开仓库;Feed 分页、文章大纲、标签云与站点品牌设置。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 16:58:22 +08:00

276 lines
7.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package config
import (
"flag"
"fmt"
"os"
"path/filepath"
"strings"
)
// Config 应用全局配置:默认读工作目录下 app.ini命令行可覆盖
type Config struct {
// 工作目录(默认可执行文件所在目录)
WorkPath string
// 配置文件绝对路径
ConfigFile string
// 监听端口
Port int
// 对外公网根地址无尾斜杠OIDC Issuer 使用
RootURL string
// 数据目录SQLite、上传、日志绝对路径
DataDir string
// JWT 签名密钥
JWTSecret string
// OIDC 客户端P0写死在 app.ini供 Gitea 对接)
OAuthClientID string
OAuthClientSecret string
OAuthRedirectURIs []string
// Gitea API 同步种子(可选,运行时以管理后台为准)
GiteaBaseURL string
GiteaToken string
GiteaSyncEnabled bool
// 日志文件路径
LogFile string
// 系统服务控制动作install|uninstall|start|stop|restart|status空表示正常运行
ServiceAction string
}
// Parse 解析命令行与 app.ini并初始化数据目录
//
// 优先级(高 → 低):命令行显式参数 > app.ini > 内置默认值
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 签名密钥(覆盖配置文件;留空则自动生成)")
serviceFlag := flag.String("service", "", "系统服务控制install|uninstall|start|stop|restart|status")
flag.Parse()
action := strings.ToLower(strings.TrimSpace(*serviceFlag))
if action != "" && !validServiceAction(action) {
return nil, fmt.Errorf("无效的 -service 动作 %q可选install|uninstall|start|stop|restart|status", *serviceFlag)
}
workPath, err := resolveWorkPath(*workFlag)
if err != nil {
return nil, err
}
configFile, err := resolveConfigPath(workPath, *configFlag)
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
if *portFlag > 0 {
port = *portFlag
}
dataInput := fileCfg.DataRel
if strings.TrimSpace(*dataFlag) != "" {
dataInput = *dataFlag
}
absData, err := absPath(workPath, dataInput)
if err != nil {
return nil, fmt.Errorf("解析数据目录失败: %w", err)
}
jwtSecret := strings.TrimSpace(*jwtFlag)
if jwtSecret == "" {
jwtSecret = fileCfg.JWTSecret
}
cfg := &Config{
WorkPath: workPath,
ConfigFile: configFile,
Port: port,
RootURL: normalizeRootURL(fileCfg.RootURL),
DataDir: absData,
JWTSecret: jwtSecret,
OAuthClientID: fileCfg.OAuthClientID,
OAuthClientSecret: fileCfg.OAuthClientSecret,
OAuthRedirectURIs: splitCSV(fileCfg.OAuthRedirectURIs),
GiteaBaseURL: normalizeRootURL(fileCfg.GiteaBaseURL),
GiteaToken: fileCfg.GiteaToken,
GiteaSyncEnabled: fileCfg.GiteaSyncEnabled,
LogFile: filepath.Join(absData, "jiang13.log"),
ServiceAction: action,
}
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,
RootURL: fileCfg.RootURL,
OAuthClientID: fileCfg.OAuthClientID,
OAuthClientSecret: fileCfg.OAuthClientSecret,
OAuthRedirectURIs: fileCfg.OAuthRedirectURIs,
}); err != nil {
return nil, fmt.Errorf("更新配置文件失败: %w", err)
}
}
if err := ensureDataDirs(absData); err != nil {
return nil, err
}
if err := cfg.resolveJWT(); err != nil {
return nil, err
}
}
return cfg, nil
}
func resolveWorkPath(flagVal string) (string, error) {
if strings.TrimSpace(flagVal) != "" {
abs, err := filepath.Abs(flagVal)
if err != nil {
return "", fmt.Errorf("解析工作目录失败: %w", err)
}
return filepath.Clean(abs), nil
}
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)
}
for _, sub := range []string{
filepath.Join(dataDir, "uploads", "avatars"),
filepath.Join(dataDir, "uploads", "posts"),
filepath.Join(dataDir, "uploads", "site"),
} {
if err := os.MkdirAll(sub, 0755); err != nil {
return fmt.Errorf("创建上传目录失败: %w", err)
}
}
return nil
}
func (c *Config) resolveJWT() error {
secretFile := filepath.Join(c.DataDir, ".jwt_secret")
if c.JWTSecret != "" {
_ = os.WriteFile(secretFile, []byte(c.JWTSecret), 0600)
return nil
}
if data, err := os.ReadFile(secretFile); err == nil && len(data) > 0 {
c.JWTSecret = string(data)
return nil
}
c.JWTSecret = generateRandomSecret(32)
if err := os.WriteFile(secretFile, []byte(c.JWTSecret), 0600); err != nil {
return fmt.Errorf("写入 JWT 密钥失败: %w", err)
}
return nil
}
func validServiceAction(action string) bool {
switch action {
case "install", "uninstall", "start", "stop", "restart", "status":
return true
default:
return false
}
}
// DBPath 返回 SQLite 数据库文件路径
func (c *Config) DBPath() string {
return filepath.Join(c.DataDir, "jiang13.db")
}
// 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")
}
// UploadDir 返回头像上传目录(兼容旧调用)
func (c *Config) UploadDir() string {
return c.AvatarUploadDir()
}
// FilterWordsPath 返回敏感词配置文件路径
func (c *Config) FilterWordsPath() string {
return filepath.Join(c.DataDir, "filter_words.txt")
}
// OIDCEnabled 是否已配置可作为 OIDC Provider
func (c *Config) OIDCEnabled() bool {
return c.RootURL != "" && c.OAuthClientID != "" && c.OAuthClientSecret != "" && len(c.OAuthRedirectURIs) > 0
}
func normalizeRootURL(raw string) string {
u := strings.TrimSpace(raw)
u = strings.TrimRight(u, "/")
return u
}
func splitCSV(raw string) []string {
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
func generateRandomSecret(n int) string {
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, n)
for i := range b {
b[i] = chars[i%len(chars)]
}
return string(b)
}