引入类 Gitea 的 app.ini、Windows Service/systemd 控制;前端增加侧栏抽屉、回到顶部、标签输入与浮层 a11y。 Co-authored-by: Cursor <cursoragent@cursor.com>
219 lines
6.1 KiB
Go
219 lines
6.1 KiB
Go
package config
|
||
|
||
import (
|
||
"flag"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
)
|
||
|
||
// 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
|
||
}
|
||
|
||
// 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,
|
||
DataDir: absData,
|
||
JWTSecret: jwtSecret,
|
||
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, port, 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 := ""
|
||
if strings.TrimSpace(*jwtFlag) != "" {
|
||
iniJWT = jwtSecret
|
||
}
|
||
if err := writeAppINI(configFile, port, dataRel, iniJWT); 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"),
|
||
} {
|
||
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")
|
||
}
|
||
|
||
// UploadDir 返回头像上传目录(兼容旧调用)
|
||
func (c *Config) UploadDir() string {
|
||
return c.AvatarUploadDir()
|
||
}
|
||
|
||
// FilterWordsPath 返回敏感词配置文件路径
|
||
func (c *Config) FilterWordsPath() string {
|
||
return filepath.Join(c.DataDir, "filter_words.txt")
|
||
}
|
||
|
||
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)
|
||
}
|