feat: opaque session、安装/发帖 SSR 与最小 Admin 后台

浏览器登录改为 DB sessions(可吊销);敏感词与 OIDC PEM 入 settings;
落地安装向导、注册发帖与 /admin 仪表盘/板块/审核/设置。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-29 05:44:16 +08:00
parent 3f50316ad0
commit fde5f628ec
80 changed files with 4148 additions and 1849 deletions

View File

@@ -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
}