diff --git a/README.md b/README.md index c2715eae0c..860a3d1a11 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/README_CN.md b/README_CN.md index 0ace1f77a6..824b6eef48 100644 --- a/README_CN.md +++ b/README_CN.md @@ -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 diff --git a/README_JA.md b/README_JA.md index d74ca9cebf..3f080ac020 100644 --- a/README_JA.md +++ b/README_JA.md @@ -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 diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index dd9a4e588f..7d994c9efc 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -17,6 +17,11 @@ import ( const ( RunModeStandard = "standard" RunModeSimple = "simple" + dockerDataDir = "/app/data" + systemConfigDir = "/etc/sub2api" + + DatabaseSSLModePrefer = "prefer" + DatabaseSSLModeDisable = "disable" ) // 使用量记录队列溢出策略 @@ -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 { @@ -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() @@ -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) } diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index cf58316c31..44e59002d7 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -1,6 +1,7 @@ package config import ( + "fmt" "os" "path/filepath" "strings" @@ -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) @@ -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") diff --git a/backend/internal/repository/ent.go b/backend/internal/repository/ent.go index 64d321924d..3bfde49d34 100644 --- a/backend/internal/repository/ent.go +++ b/backend/internal/repository/ent.go @@ -6,6 +6,7 @@ import ( "context" "database/sql" "fmt" + "log/slog" "time" "github.com/Wei-Shaw/sub2api/ent" @@ -44,15 +45,10 @@ func InitEnt(cfg *config.Config) (*ent.Client, *sql.DB, error) { // 构建包含时区信息的数据库连接字符串 (DSN)。 // 时区信息会传递给 PostgreSQL,确保数据库层面的时间处理正确。 - dsn := cfg.Database.DSNWithTimezone(cfg.Timezone) - - // 使用 Ent 的 SQL 驱动打开 PostgreSQL 连接。 - // dialect.Postgres 指定使用 PostgreSQL 方言进行 SQL 生成。 - drv, err := entsql.Open(dialect.Postgres, dsn) + drv, err := openEntDriverWithSSLFallback(cfg) if err != nil { return nil, nil, err } - applyDBPoolSettings(drv.DB(), cfg) // 确保数据库 schema 已准备就绪。 // SQL 迁移文件是 schema 的权威来源(source of truth)。 @@ -97,3 +93,48 @@ func InitEnt(cfg *config.Config) (*ent.Client, *sql.DB, error) { return client, drv.DB(), nil } + +func openEntDriverWithSSLFallback(cfg *config.Config) (*entsql.Driver, error) { + openDriver := func() (*entsql.Driver, error) { + dsn := cfg.Database.DSNWithTimezone(cfg.Timezone) + drv, err := entsql.Open(dialect.Postgres, dsn) + if err != nil { + return nil, err + } + applyDBPoolSettings(drv.DB(), cfg) + return drv, nil + } + + pingDriver := func(drv *entsql.Driver) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return drv.DB().PingContext(ctx) + } + + drv, err := openDriver() + if err != nil { + return nil, err + } + + if err := pingDriver(drv); err != nil { + if !config.ShouldFallbackDatabaseSSLModeToDisable(cfg.Database.SSLMode, err) { + _ = drv.Close() + return nil, err + } + + slog.Warn("database.sslmode=prefer unsupported by PostgreSQL driver; falling back to disable") + _ = drv.Close() + cfg.Database.SSLMode = config.DatabaseSSLModeDisable + + drv, err = openDriver() + if err != nil { + return nil, err + } + if err := pingDriver(drv); err != nil { + _ = drv.Close() + return nil, err + } + } + + return drv, nil +} diff --git a/backend/internal/setup/handler.go b/backend/internal/setup/handler.go index c2944cedfb..99ac8885a7 100644 --- a/backend/internal/setup/handler.go +++ b/backend/internal/setup/handler.go @@ -9,6 +9,7 @@ import ( "sync" "time" + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/pkg/sysutil" @@ -229,6 +230,25 @@ type InstallRequest struct { Server ServerConfig `json:"server"` } +func resolveInstallServerConfig(req ServerConfig) ServerConfig { + bootstrap := config.GetBootstrapServerConfig() + mode := strings.TrimSpace(req.Mode) + if mode == "" { + mode = strings.TrimSpace(bootstrap.Mode) + } + if mode == "" { + mode = "release" + } + + // Web setup should persist the backend's own listen address instead of + // trusting the browser-facing port forwarded by the frontend. + return ServerConfig{ + Host: bootstrap.Host, + Port: bootstrap.Port, + Mode: mode, + } +} + // install performs the installation func install(c *gin.Context) { // TOCTOU Protection: Acquire mutex to prevent concurrent installation @@ -296,6 +316,8 @@ func install(c *gin.Context) { return } + req.Server = resolveInstallServerConfig(req.Server) + // Server validation if req.Server.Port != 0 && !validatePort(req.Server.Port) { response.Error(c, http.StatusBadRequest, "Invalid server port") diff --git a/backend/internal/setup/setup.go b/backend/internal/setup/setup.go index 9256d24575..44ef292a31 100644 --- a/backend/internal/setup/setup.go +++ b/backend/internal/setup/setup.go @@ -38,27 +38,9 @@ func setupDefaultAdminConcurrency() int { } // GetDataDir returns the data directory for storing config and lock files. -// Priority: DATA_DIR env > /app/data (if exists and writable) > current directory +// Priority: DATA_DIR env > /app/data in container deployments > current directory. func GetDataDir() string { - // Check DATA_DIR environment variable first - if dir := os.Getenv("DATA_DIR"); dir != "" { - return dir - } - - // Check if /app/data exists and is writable (Docker environment) - dockerDataDir := "/app/data" - if info, err := os.Stat(dockerDataDir); err == nil && info.IsDir() { - // Try to check if writable by creating a temp file - testFile := dockerDataDir + "/.write_test" - if f, err := os.Create(testFile); err == nil { - _ = f.Close() - _ = os.Remove(testFile) - return dockerDataDir - } - } - - // Default to current directory - return "." + return config.ResolveDataDir() } // GetConfigFilePath returns the full path to config.yaml @@ -145,14 +127,11 @@ func decideAdminBootstrap(totalUsers, adminUsers int64) adminBootstrapDecision { } // NeedsSetup checks if the system needs initial setup -// Uses multiple checks to prevent attackers from forcing re-setup by deleting config +// Uses the installation lock file as the source of truth for whether setup +// has completed. This allows preseeded config.yaml files to coexist with the +// initial setup flow until installation is explicitly finalized. func NeedsSetup() bool { - // Check 1: Config file must not exist - if _, err := os.Stat(GetConfigFilePath()); !os.IsNotExist(err) { - return false // Config exists, no setup needed - } - - // Check 2: Installation lock file (harder to bypass) + // Installation lock file is the authoritative signal that setup finished. if _, err := os.Stat(GetInstallLockPath()); !os.IsNotExist(err) { return false // Lock file exists, already installed } @@ -160,15 +139,65 @@ func NeedsSetup() bool { return true } -// TestDatabaseConnection tests the database connection and creates database if not exists -func TestDatabaseConnection(cfg *DatabaseConfig) error { - // First, connect to the default 'postgres' database to check/create target database - defaultDSN := fmt.Sprintf( +func buildSetupPostgresDSN(cfg *DatabaseConfig, dbName string) string { + if strings.TrimSpace(dbName) == "" { + dbName = cfg.DBName + } + if cfg.Password == "" { + return fmt.Sprintf( + "host=%s port=%d user=%s dbname=%s sslmode=%s", + cfg.Host, cfg.Port, cfg.User, dbName, cfg.SSLMode, + ) + } + return fmt.Sprintf( "host=%s port=%d user=%s password=%s dbname=%s sslmode=%s", - cfg.Host, cfg.Port, cfg.User, cfg.Password, cfg.DBName, cfg.SSLMode, + cfg.Host, cfg.Port, cfg.User, cfg.Password, dbName, cfg.SSLMode, ) +} + +func pingPostgresDB(db *sql.DB, timeout time.Duration) error { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + return db.PingContext(ctx) +} + +func openPostgresWithSSLFallback(cfg *DatabaseConfig, dbName string) (*sql.DB, error) { + openDB := func() (*sql.DB, error) { + return sql.Open("postgres", buildSetupPostgresDSN(cfg, dbName)) + } + + db, err := openDB() + if err != nil { + return nil, err + } + + if err := pingPostgresDB(db, 5*time.Second); err != nil { + if !config.ShouldFallbackDatabaseSSLModeToDisable(cfg.SSLMode, err) { + _ = db.Close() + return nil, err + } + + logger.LegacyPrintf("setup", "database sslmode=prefer unsupported by driver, falling back to disable") + _ = db.Close() + cfg.SSLMode = config.DatabaseSSLModeDisable - db, err := sql.Open("postgres", defaultDSN) + db, err = openDB() + if err != nil { + return nil, err + } + if err := pingPostgresDB(db, 5*time.Second); err != nil { + _ = db.Close() + return nil, err + } + } + + return db, nil +} + +// TestDatabaseConnection tests the database connection and creates database if not exists +func TestDatabaseConnection(cfg *DatabaseConfig) error { + // First, connect to the default 'postgres' database to check/create target database. + db, err := openPostgresWithSSLFallback(cfg, "postgres") if err != nil { return fmt.Errorf("failed to connect to PostgreSQL: %w", err) } @@ -182,14 +211,9 @@ func TestDatabaseConnection(cfg *DatabaseConfig) error { } }() + // Check if target database exists ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - - if err := db.PingContext(ctx); err != nil { - return fmt.Errorf("ping failed: %w", err) - } - - // Check if target database exists var exists bool row := db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)", cfg.DBName) if err := row.Scan(&exists); err != nil { @@ -214,12 +238,7 @@ func TestDatabaseConnection(cfg *DatabaseConfig) error { } db = nil - targetDSN := fmt.Sprintf( - "host=%s port=%d user=%s password=%s dbname=%s sslmode=%s", - cfg.Host, cfg.Port, cfg.User, cfg.Password, cfg.DBName, cfg.SSLMode, - ) - - targetDB, err := sql.Open("postgres", targetDSN) + targetDB, err := openPostgresWithSSLFallback(cfg, cfg.DBName) if err != nil { return fmt.Errorf("failed to connect to database '%s': %w", cfg.DBName, err) } @@ -230,13 +249,6 @@ func TestDatabaseConnection(cfg *DatabaseConfig) error { } }() - ctx2, cancel2 := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel2() - - if err := targetDB.PingContext(ctx2); err != nil { - return fmt.Errorf("ping target database failed: %w", err) - } - return nil } diff --git a/backend/internal/setup/setup_test.go b/backend/internal/setup/setup_test.go index a01dd00c4d..b388be016a 100644 --- a/backend/internal/setup/setup_test.go +++ b/backend/internal/setup/setup_test.go @@ -87,3 +87,48 @@ func TestWriteConfigFileKeepsDefaultUserConcurrency(t *testing.T) { t.Fatalf("config missing default user concurrency, got:\n%s", string(data)) } } + +func TestNeedsSetupIgnoresExistingConfigWithoutInstallLock(t *testing.T) { + t.Setenv("DATA_DIR", t.TempDir()) + + if err := os.WriteFile(GetConfigFilePath(), []byte("server:\n port: 8080\n"), 0o600); err != nil { + t.Fatalf("WriteFile(config) error = %v", err) + } + + if !NeedsSetup() { + t.Fatalf("NeedsSetup() = false, want true when only config exists") + } +} + +func TestNeedsSetupFalseWhenInstallLockExists(t *testing.T) { + t.Setenv("DATA_DIR", t.TempDir()) + + if err := os.WriteFile(GetInstallLockPath(), []byte("installed_at=2026-04-15T00:00:00Z\n"), 0o400); err != nil { + t.Fatalf("WriteFile(lock) error = %v", err) + } + + if NeedsSetup() { + t.Fatalf("NeedsSetup() = true, want false when install lock exists") + } +} + +func TestResolveInstallServerConfigUsesBackendBootstrapPort(t *testing.T) { + t.Setenv("SERVER_HOST", "127.0.0.1") + t.Setenv("SERVER_PORT", "9090") + + got := resolveInstallServerConfig(ServerConfig{ + Host: "0.0.0.0", + Port: 443, + Mode: "release", + }) + + if got.Host != "127.0.0.1" { + t.Fatalf("Host = %q, want %q", got.Host, "127.0.0.1") + } + if got.Port != 9090 { + t.Fatalf("Port = %d, want %d", got.Port, 9090) + } + if got.Mode != "release" { + t.Fatalf("Mode = %q, want %q", got.Mode, "release") + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 7485aa1afc..04e428f260 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -15,6 +15,24 @@ const authStore = useAuthStore() const subscriptionStore = useSubscriptionStore() const announcementStore = useAnnouncementStore() +async function enforceSetupRouteState(): Promise { + try { + const status = await getSetupStatus() + if (status.needs_setup && route.path !== '/setup') { + await router.replace('/setup') + return true + } + if (!status.needs_setup && route.path === '/setup') { + await router.replace('/') + return true + } + } catch { + // If setup endpoint fails, assume normal mode and continue + } + + return false +} + /** * Update favicon dynamically * @param logoUrl - URL of the logo to use as favicon @@ -80,6 +98,15 @@ watch( { immediate: true } ) +watch( + () => route.path, + async (path) => { + if (path === '/setup') { + await enforceSetupRouteState() + } + } +) + // Route change trigger (throttled by store) router.afterEach(() => { if (authStore.isAuthenticated) { @@ -92,15 +119,8 @@ onBeforeUnmount(() => { }) onMounted(async () => { - // Check if setup is needed - try { - const status = await getSetupStatus() - if (status.needs_setup && route.path !== '/setup') { - router.replace('/setup') - return - } - } catch { - // If setup endpoint fails, assume normal mode and continue + if (await enforceSetupRouteState()) { + return } // Load public settings into appStore (will be cached for other components) diff --git a/frontend/src/views/setup/SetupWizardView.vue b/frontend/src/views/setup/SetupWizardView.vue index 5774899e89..e2b94fd1e2 100644 --- a/frontend/src/views/setup/SetupWizardView.vue +++ b/frontend/src/views/setup/SetupWizardView.vue @@ -519,16 +519,6 @@ const installing = ref(false) const confirmPassword = ref('') const serviceReady = ref(false) -// Default server port -const getCurrentPort = (): number => { - const port = window.location.port - if (port) { - return parseInt(port, 10) - } - - return window.location.protocol === 'https:' ? 443 : 80 -} - const formData = reactive({ database: { host: 'localhost', @@ -551,7 +541,8 @@ const formData = reactive({ }, server: { host: '0.0.0.0', - port: getCurrentPort(), // Use current port from browser + // Resolved by the backend from service config / environment instead of browser URL. + port: 0, mode: 'release' } })