349 lines
11 KiB
Go
349 lines
11 KiB
Go
package config
|
||
|
||
import (
|
||
"flag"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
)
|
||
|
||
// StorageTypeLocal / StorageTypeS3 上传存储后端
|
||
const (
|
||
StorageTypeLocal = "local"
|
||
StorageTypeS3 = "s3"
|
||
)
|
||
|
||
// S3Config S3 兼容对象存储(MinIO / 七牛 / 又拍 / 阿里云 OSS 等)
|
||
type S3Config struct {
|
||
Endpoint string // 例:https://s3.example.com 或 s3.example.com:9000
|
||
Region string
|
||
Bucket string
|
||
AccessKey string
|
||
SecretKey string
|
||
PublicBaseURL string // 公开访问根 URL(无尾斜杠),上传后返回此前缀下的绝对地址
|
||
Prefix string // 对象 key 前缀(可选,如 forum/)
|
||
ForcePathStyle bool // path-style(MinIO 等通常为 true;AWS 官方多为 false)
|
||
}
|
||
|
||
// 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
|
||
// 上传存储:local(默认)或 s3
|
||
StorageType string
|
||
S3 S3Config
|
||
// 日志文件路径
|
||
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
|
||
}
|
||
|
||
storageType := strings.ToLower(strings.TrimSpace(fileCfg.StorageType))
|
||
if storageType == "" {
|
||
storageType = StorageTypeLocal
|
||
}
|
||
if storageType != StorageTypeLocal && storageType != StorageTypeS3 {
|
||
return nil, fmt.Errorf("storage.TYPE 无效: %q(可选 local / s3)", fileCfg.StorageType)
|
||
}
|
||
|
||
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,
|
||
StorageType: storageType,
|
||
S3: S3Config{
|
||
Endpoint: strings.TrimSpace(fileCfg.S3Endpoint),
|
||
Region: strings.TrimSpace(fileCfg.S3Region),
|
||
Bucket: strings.TrimSpace(fileCfg.S3Bucket),
|
||
AccessKey: strings.TrimSpace(fileCfg.S3AccessKey),
|
||
SecretKey: strings.TrimSpace(fileCfg.S3SecretKey),
|
||
PublicBaseURL: normalizeRootURL(fileCfg.S3PublicBaseURL),
|
||
Prefix: normalizeStoragePrefix(fileCfg.S3Prefix),
|
||
ForcePathStyle: fileCfg.S3ForcePathStyle,
|
||
},
|
||
LogFile: filepath.Join(absData, "jiang13.log"),
|
||
ServiceAction: action,
|
||
}
|
||
|
||
// [storage] 仅作首次种子;运行时以管理后台为准,此处不强制校验 S3 完整性
|
||
|
||
needDirs := action == "" || action == "install"
|
||
if needDirs {
|
||
// 首次启动自动生成 app.ini,便于像 Gitea 一样改文件而不记一长串参数
|
||
if !configExists {
|
||
dataRel := resolveDataRelForINI(workPath, absData)
|
||
if err := writeAppINI(configFile, fileSettings{
|
||
Port: port,
|
||
DataRel: dataRel,
|
||
StorageType: StorageTypeLocal,
|
||
S3ForcePathStyle: true,
|
||
S3Region: "us-east-1",
|
||
}); 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,
|
||
GiteaBaseURL: fileCfg.GiteaBaseURL,
|
||
GiteaToken: fileCfg.GiteaToken,
|
||
GiteaSyncEnabled: fileCfg.GiteaSyncEnabled,
|
||
StorageType: fileCfg.StorageType,
|
||
S3Endpoint: fileCfg.S3Endpoint,
|
||
S3Region: fileCfg.S3Region,
|
||
S3Bucket: fileCfg.S3Bucket,
|
||
S3AccessKey: fileCfg.S3AccessKey,
|
||
S3SecretKey: fileCfg.S3SecretKey,
|
||
S3PublicBaseURL: fileCfg.S3PublicBaseURL,
|
||
S3Prefix: fileCfg.S3Prefix,
|
||
S3ForcePathStyle: fileCfg.S3ForcePathStyle,
|
||
}); 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")
|
||
}
|
||
|
||
// FilterWordsPath 返回敏感词配置文件路径
|
||
func (c *Config) FilterWordsPath() string {
|
||
return filepath.Join(c.DataDir, "filter_words.txt")
|
||
}
|
||
|
||
func normalizeRootURL(raw string) string {
|
||
u := strings.TrimSpace(raw)
|
||
u = strings.TrimRight(u, "/")
|
||
return u
|
||
}
|
||
|
||
// normalizeStoragePrefix 规范化对象 key 前缀:去首尾空白与首斜杠,非空时保证尾斜杠
|
||
func normalizeStoragePrefix(raw string) string {
|
||
p := strings.TrimSpace(raw)
|
||
p = strings.TrimPrefix(p, "/")
|
||
if p == "" {
|
||
return ""
|
||
}
|
||
return strings.TrimSuffix(p, "/") + "/"
|
||
}
|
||
|
||
func (s S3Config) validate() error {
|
||
if s.Endpoint == "" {
|
||
return fmt.Errorf("storage.TYPE=s3 时必须配置 ENDPOINT")
|
||
}
|
||
if s.Bucket == "" {
|
||
return fmt.Errorf("storage.TYPE=s3 时必须配置 BUCKET")
|
||
}
|
||
if s.AccessKey == "" || s.SecretKey == "" {
|
||
return fmt.Errorf("storage.TYPE=s3 时必须配置 ACCESS_KEY 与 SECRET_KEY")
|
||
}
|
||
if s.PublicBaseURL == "" {
|
||
return fmt.Errorf("storage.TYPE=s3 时必须配置 PUBLIC_BASE_URL(公开访问根地址)")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
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)
|
||
}
|