Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,29 @@ nano config.yaml

> **Note:** The `-tags embed` flag embeds the frontend into the binary. Without this flag, the binary will not serve the frontend UI.

#### Runtime config path for source builds

When running a source build, the setup wizard and normal startup read/write `config.yaml` from the current working directory by default. For example, if you run the binary inside `backend`, the default path is `backend/config.yaml`.

If you want runtime files such as `config.yaml` and `.installed` to live in a dedicated data directory, set `DATA_DIR` in your service environment:

```ini
[Service]
WorkingDirectory=/opt/sub2api/backend
Environment=DATA_DIR=/var/lib/sub2api
Environment=SERVER_HOST=0.0.0.0
Environment=SERVER_PORT=8080
```

Create the directory first and ensure the service user can write to it:

```bash
sudo mkdir -p /var/lib/sub2api
sudo chown -R sub2api:sub2api /var/lib/sub2api
```

Docker deployments still use `/app/data` as before.

**Key configuration in `config.yaml`:**

```yaml
Expand Down
23 changes: 23 additions & 0 deletions README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,29 @@ nano config.yaml

> **注意:** `-tags embed` 参数会将前端嵌入到二进制文件中。不使用此参数编译的程序将不包含前端界面。

#### 源码编译时的运行配置路径

源码编译运行时,初始化向导和正常启动默认都会在当前工作目录读写 `config.yaml`。例如你在 `backend` 目录运行二进制时,默认路径就是 `backend/config.yaml`。

如果你希望把 `config.yaml`、`.installed` 等运行时文件放到独立数据目录,请在服务环境变量中显式设置 `DATA_DIR`:

```ini
[Service]
WorkingDirectory=/opt/sub2api/backend
Environment=DATA_DIR=/var/lib/sub2api
Environment=SERVER_HOST=0.0.0.0
Environment=SERVER_PORT=8080
```

请先创建目录,并确保服务用户有写权限:

```bash
sudo mkdir -p /var/lib/sub2api
sudo chown -R sub2api:sub2api /var/lib/sub2api
```

Docker 部署仍然和以前一样,继续使用 `/app/data`。

**`config.yaml` 关键配置:**

```yaml
Expand Down
23 changes: 23 additions & 0 deletions README_JA.md
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,29 @@ nano config.yaml

> **注意:** `-tags embed` フラグはフロントエンドをバイナリに組み込みます。このフラグがない場合、バイナリはフロントエンド UI を提供しません。

#### ソースビルド時の実行時設定パス

ソースからビルドして実行する場合、セットアップウィザードと通常起動はデフォルトで現在の作業ディレクトリにある `config.yaml` を読み書きします。たとえば `backend` ディレクトリでバイナリを実行する場合、デフォルトパスは `backend/config.yaml` です。

`config.yaml` や `.installed` などの実行時ファイルを専用のデータディレクトリに置きたい場合は、サービス環境変数で `DATA_DIR` を明示的に設定してください:

```ini
[Service]
WorkingDirectory=/opt/sub2api/backend
Environment=DATA_DIR=/var/lib/sub2api
Environment=SERVER_HOST=0.0.0.0
Environment=SERVER_PORT=8080
```

先にディレクトリを作成し、サービスユーザーに書き込み権限を付与してください:

```bash
sudo mkdir -p /var/lib/sub2api
sudo chown -R sub2api:sub2api /var/lib/sub2api
```

Docker デプロイは従来どおり `/app/data` を使用します。

**`config.yaml` の主要設定:**

```yaml
Expand Down
182 changes: 159 additions & 23 deletions backend/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ import (
const (
RunModeStandard = "standard"
RunModeSimple = "simple"
dockerDataDir = "/app/data"
systemConfigDir = "/etc/sub2api"

DatabaseSSLModePrefer = "prefer"
DatabaseSSLModeDisable = "disable"
)

// 使用量记录队列溢出策略
Expand Down Expand Up @@ -743,6 +748,20 @@ func (d *DatabaseConfig) DSNWithTimezone(tz string) string {
)
}

func NormalizeDatabaseSSLMode(mode string) string {
return strings.ToLower(strings.TrimSpace(mode))
}

func ShouldFallbackDatabaseSSLModeToDisable(mode string, err error) bool {
if NormalizeDatabaseSSLMode(mode) != DatabaseSSLModePrefer || err == nil {
return false
}

msg := strings.ToLower(err.Error())
return strings.Contains(msg, `unsupported sslmode "prefer"`) ||
strings.Contains(msg, "unsupported sslmode prefer")
}

// RedisConfig Redis 连接配置
// 性能优化:新增连接池和超时参数,提升高并发场景下的吞吐量
type RedisConfig struct {
Expand Down Expand Up @@ -951,23 +970,118 @@ func LoadForBootstrap() (*Config, error) {
return load(true)
}

func buildConfigSearchPaths(dataDir string, preferContainerDataDir bool) []string {
paths := make([]string, 0, 5)
dataDir = strings.TrimSpace(dataDir)
if dataDir != "" {
paths = append(paths, dataDir)
}
if preferContainerDataDir {
paths = append(paths, dockerDataDir)
}

paths = append(paths, ".", "./config", systemConfigDir)

// Outside Docker, keep /app/data as a last-resort compatibility fallback
// so old manual deployments can still be discovered without overriding
// the current working directory's config.
if !preferContainerDataDir {
paths = append(paths, dockerDataDir)
}

seen := make(map[string]struct{}, len(paths))
out := make([]string, 0, len(paths))
for _, path := range paths {
trimmed := strings.TrimSpace(path)
if trimmed == "" {
continue
}
if _, exists := seen[trimmed]; exists {
continue
}
seen[trimmed] = struct{}{}
out = append(out, trimmed)
}
return out
}

func addConfigSearchPaths(v *viper.Viper) {
for _, path := range buildConfigSearchPaths(os.Getenv("DATA_DIR"), shouldPreferContainerDataDir()) {
v.AddConfigPath(path)
}
}

func isTruthyEnvValue(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}

func shouldPreferContainerDataDir() bool {
if isTruthyEnvValue(os.Getenv("AUTO_SETUP")) {
return true
}
if _, err := os.Stat("/.dockerenv"); err == nil {
return true
}
return false
}

func isWritableDir(dir string) bool {
info, err := os.Stat(dir)
if err != nil || !info.IsDir() {
return false
}

testFile := dir + "/.write_test"
f, err := os.Create(testFile)
if err != nil {
return false
}
_ = f.Close()
_ = os.Remove(testFile)
return true
}

func resolveWritableDataDir(dataDir string, preferContainerDataDir bool) string {
dataDir = strings.TrimSpace(dataDir)
if dataDir != "" {
return dataDir
}
if preferContainerDataDir && isWritableDir(dockerDataDir) {
return dockerDataDir
}
return "."
}

// ResolveDataDir returns the primary writable data directory used by setup.
// Priority: DATA_DIR environment variable > /app/data in container deployments > current directory.
func ResolveDataDir() string {
return resolveWritableDataDir(os.Getenv("DATA_DIR"), shouldPreferContainerDataDir())
}

// FindConfigFile returns the first discovered config file according to the
// runtime search order. An empty string means no config file was found.
func FindConfigFile() string {
v := viper.New()
v.SetConfigName("config")
v.SetConfigType("yaml")
addConfigSearchPaths(v)
if err := v.ReadInConfig(); err != nil {
return ""
}
return v.ConfigFileUsed()
}

func load(allowMissingJWTSecret bool) (*Config, error) {
viper.SetConfigName("config")
viper.SetConfigType("yaml")

// Add config paths in priority order
// 1. DATA_DIR environment variable (highest priority)
if dataDir := os.Getenv("DATA_DIR"); dataDir != "" {
viper.AddConfigPath(dataDir)
}
// 2. Docker data directory
viper.AddConfigPath("/app/data")
// 3. Current directory
viper.AddConfigPath(".")
// 4. Config subdirectory
viper.AddConfigPath("./config")
// 5. System config directory
viper.AddConfigPath("/etc/sub2api")
// Add config paths in runtime priority order.
addConfigSearchPaths(viper.GetViper())

// 环境变量支持
viper.AutomaticEnv()
Expand Down Expand Up @@ -2276,29 +2390,51 @@ func generateJWTSecret(byteLength int) (string, error) {
return hex.EncodeToString(buf), nil
}

// GetServerAddress returns the server address (host:port) from config file or environment variable.
// This is a lightweight function that can be used before full config validation,
// such as during setup wizard startup.
// Priority: config.yaml > environment variables > defaults
func GetServerAddress() string {
// GetBootstrapServerConfig returns the lightweight server config used before
// full validation, such as during setup wizard startup.
// Priority: config search paths + environment overrides + defaults.
func GetBootstrapServerConfig() ServerConfig {
v := viper.New()
v.SetConfigName("config")
v.SetConfigType("yaml")
v.AddConfigPath(".")
v.AddConfigPath("./config")
v.AddConfigPath("/etc/sub2api")
addConfigSearchPaths(v)

// Support SERVER_HOST and SERVER_PORT environment variables
// Support SERVER_HOST and SERVER_PORT environment variables.
v.AutomaticEnv()
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.SetDefault("server.host", "0.0.0.0")
v.SetDefault("server.port", 8080)
v.SetDefault("server.mode", "release")

// Try to read config file (ignore errors if not found)
// Try to read config file (ignore errors if not found).
_ = v.ReadInConfig()

host := v.GetString("server.host")
port := v.GetInt("server.port")
mode := strings.ToLower(strings.TrimSpace(v.GetString("server.mode")))
if host == "" {
host = "0.0.0.0"
}
if port <= 0 {
port = 8080
}
if mode == "" {
mode = "release"
}

return ServerConfig{
Host: host,
Port: port,
Mode: mode,
}
}

// GetServerAddress returns the bootstrap server address (host:port) used
// before full config validation, such as during setup wizard startup.
func GetServerAddress() string {
cfg := GetBootstrapServerConfig()
host := cfg.Host
port := cfg.Port
return fmt.Sprintf("%s:%d", host, port)
}

Expand Down
30 changes: 30 additions & 0 deletions backend/internal/config/config_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package config

import (
"fmt"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -692,6 +693,23 @@ func TestGetServerAddressFromEnv(t *testing.T) {
}
}

func TestBuildConfigSearchPathsOutsideContainerKeepsAppDataAsFallback(t *testing.T) {
paths := buildConfigSearchPaths("", false)
expected := []string{".", "./config", systemConfigDir, dockerDataDir}
require.Equal(t, expected, paths)
}

func TestBuildConfigSearchPathsInContainerPrefersAppData(t *testing.T) {
paths := buildConfigSearchPaths("", true)
expected := []string{dockerDataDir, ".", "./config", systemConfigDir}
require.Equal(t, expected, paths)
}

func TestResolveWritableDataDirOutsideContainerDefaultsToCurrentDirectory(t *testing.T) {
dir := resolveWritableDataDir("", false)
require.Equal(t, ".", dir)
}

func TestValidateAbsoluteHTTPURL(t *testing.T) {
if err := ValidateAbsoluteHTTPURL("https://example.com/path"); err != nil {
t.Fatalf("ValidateAbsoluteHTTPURL valid url error: %v", err)
Expand Down Expand Up @@ -883,6 +901,18 @@ func TestDatabaseDSNWithTimezone_WithPassword(t *testing.T) {
}
}

func TestShouldFallbackDatabaseSSLModeToDisable(t *testing.T) {
if !ShouldFallbackDatabaseSSLModeToDisable("prefer", fmt.Errorf(`pq: unsupported sslmode "prefer"`)) {
t.Fatalf("ShouldFallbackDatabaseSSLModeToDisable() = false, want true")
}
if ShouldFallbackDatabaseSSLModeToDisable("disable", fmt.Errorf(`pq: unsupported sslmode "prefer"`)) {
t.Fatalf("ShouldFallbackDatabaseSSLModeToDisable() = true, want false for disable")
}
if ShouldFallbackDatabaseSSLModeToDisable("prefer", fmt.Errorf("some other error")) {
t.Fatalf("ShouldFallbackDatabaseSSLModeToDisable() = true, want false for unrelated error")
}
}

func TestValidateAbsoluteHTTPURLMissingHost(t *testing.T) {
if err := ValidateAbsoluteHTTPURL("https://"); err == nil {
t.Fatalf("ValidateAbsoluteHTTPURL should reject missing host")
Expand Down
Loading