From ce170cbd4198e4e7fa8e85d46d2b825259dce5c0 Mon Sep 17 00:00:00 2001 From: saltbo Date: Sat, 6 Jun 2026 02:01:02 -0400 Subject: [PATCH] fix(cli): keep auth token in global config --- cmd/internal/config/config.go | 145 ++++++++++++++++++++++++++++- cmd/internal/config/config_test.go | 72 +++++++++++++- cmd/zpan/main.go | 25 +---- 3 files changed, 219 insertions(+), 23 deletions(-) diff --git a/cmd/internal/config/config.go b/cmd/internal/config/config.go index ec1728e3..87559d18 100644 --- a/cmd/internal/config/config.go +++ b/cmd/internal/config/config.go @@ -2,8 +2,10 @@ package config import ( "errors" + "fmt" "os" "path/filepath" + "strconv" "strings" "time" @@ -40,6 +42,7 @@ const ( func Defaults(v *viper.Viper) { home, _ := os.UserHomeDir() v.SetDefault("server_url", "http://localhost:5173") + v.SetDefault("token", "") v.SetDefault("downloader.engine", "auto") v.SetDefault("downloader.download_dir", filepath.Join(home, "Downloads", "zpan")) v.SetDefault("downloader.state_dir", defaultStateDir(home)) @@ -85,7 +88,7 @@ func Load(v *viper.Viper) (Config, error) { cfg := Config{ ServerURL: strings.TrimRight(v.GetString("server_url"), "/"), - Token: v.GetString("downloader.token"), + Token: v.GetString("token"), Engine: v.GetString("downloader.engine"), DownloadDir: v.GetString("downloader.download_dir"), StateDir: v.GetString("downloader.state_dir"), @@ -132,6 +135,146 @@ func explicitValue(v *viper.Viper, key string) bool { return v.IsSet(key) } +func WriteDefaultConfig(path string) error { + home, _ := os.UserHomeDir() + cfg := Config{ + ServerURL: "http://localhost:5173", + Engine: "auto", + DownloadDir: filepath.Join(home, "Downloads", "zpan"), + StateDir: defaultStateDir(home), + PollInterval: 5 * time.Second, + MaxConcurrentTasks: 2, + SeedEnabled: true, + SeedDuration: time.Hour, + SeedCacheLimit: 10_000_000_000, + SeedRatio: 0, + } + return createConfigFile(path, defaultConfigYAML(cfg)) +} + +func WriteConfig(path string, cfg Config, token string) error { + cfg.Token = token + return writeConfigFile(path, configYAML(cfg, false)) +} + +func createConfigFile(path string, content string) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return err + } + defer file.Close() + _, err = file.WriteString(content) + return err +} + +func writeConfigFile(path string, content string) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + return os.WriteFile(path, []byte(content), 0o600) +} + +func defaultConfigYAML(cfg Config) string { + return "# ZPan CLI configuration\n" + + "# token is written automatically after device login.\n" + + "# token: \"\"\n\n" + + configYAML(cfg, true) +} + +func configYAML(cfg Config, includeRuntimeHints bool) string { + var b strings.Builder + fmt.Fprintf(&b, "server_url: %s\n", yamlString(cfg.ServerURL)) + if cfg.Token != "" { + fmt.Fprintf(&b, "token: %s\n", yamlString(cfg.Token)) + } + b.WriteString("downloader:\n") + fmt.Fprintf(&b, " engine: %s\n", yamlString(nonEmpty(cfg.Engine, "auto"))) + fmt.Fprintf(&b, " download_dir: %s\n", yamlString(cfg.DownloadDir)) + fmt.Fprintf(&b, " state_dir: %s\n", yamlString(cfg.StateDir)) + fmt.Fprintf(&b, " poll_interval: %s\n", yamlString(formatDuration(cfg.PollInterval, "5s"))) + fmt.Fprintf(&b, " max_concurrent_tasks: %d\n", cfg.MaxConcurrentTasks) + b.WriteString(" seed:\n") + fmt.Fprintf(&b, " enabled: %t\n", cfg.SeedEnabled) + fmt.Fprintf(&b, " duration: %s\n", yamlString(formatDuration(cfg.SeedDuration, "1h"))) + fmt.Fprintf(&b, " cache_limit: %s\n", yamlString(formatSeedCacheLimit(cfg.SeedCacheLimit))) + fmt.Fprintf(&b, " ratio: %s\n", strconv.FormatFloat(cfg.SeedRatio, 'f', -1, 64)) + if shouldWriteAria2Config(cfg) { + b.WriteString(" aria2:\n") + fmt.Fprintf(&b, " url: %s\n", yamlString(nonEmpty(cfg.Aria2URL, DefaultAria2URL))) + if cfg.Aria2Secret != "" { + fmt.Fprintf(&b, " secret: %s\n", yamlString(cfg.Aria2Secret)) + } + } + if shouldWriteQBittorrentConfig(cfg) { + b.WriteString(" qbittorrent:\n") + fmt.Fprintf(&b, " url: %s\n", yamlString(nonEmpty(cfg.QBittorrentURL, DefaultQBittorrentURL))) + if cfg.QBittorrentUser != "" { + fmt.Fprintf(&b, " username: %s\n", yamlString(cfg.QBittorrentUser)) + } + if cfg.QBittorrentPass != "" { + fmt.Fprintf(&b, " password: %s\n", yamlString(cfg.QBittorrentPass)) + } + } + if includeRuntimeHints { + b.WriteString("\n") + b.WriteString(" # To connect an external aria2 runtime, set engine to \"aria2\"\n") + b.WriteString(" # and uncomment this block.\n") + fmt.Fprintf(&b, " # aria2:\n") + fmt.Fprintf(&b, " # url: %s\n", yamlString(DefaultAria2URL)) + fmt.Fprintf(&b, " # secret: %s\n", yamlString("optional-rpc-secret")) + b.WriteString("\n") + b.WriteString(" # To connect an external qBittorrent runtime, set engine to \"qbittorrent\"\n") + b.WriteString(" # and uncomment this block.\n") + fmt.Fprintf(&b, " # qbittorrent:\n") + fmt.Fprintf(&b, " # url: %s\n", yamlString(DefaultQBittorrentURL)) + fmt.Fprintf(&b, " # username: %s\n", yamlString("admin")) + fmt.Fprintf(&b, " # password: %s\n", yamlString("password")) + } + return b.String() +} + +func shouldWriteAria2Config(cfg Config) bool { + return strings.EqualFold(cfg.Engine, "aria2") || cfg.Aria2Configured +} + +func shouldWriteQBittorrentConfig(cfg Config) bool { + return strings.EqualFold(cfg.Engine, "qbittorrent") || cfg.QBittorrentConfigured +} + +func yamlString(value string) string { + return strconv.Quote(value) +} + +func nonEmpty(value string, fallback string) string { + if value == "" { + return fallback + } + return value +} + +func formatSeedCacheLimit(value int64) string { + if value == 10_000_000_000 { + return "10GB" + } + return strconv.FormatInt(value, 10) +} + +func formatDuration(value time.Duration, fallback string) string { + if value == 0 { + return fallback + } + if value == time.Hour { + return "1h" + } + if value%time.Second == 0 { + return value.String() + } + return value.String() +} + func parseBytes(value string) (int64, error) { value = strings.TrimSpace(value) if value == "" { diff --git a/cmd/internal/config/config_test.go b/cmd/internal/config/config_test.go index 90e5df71..323fe87f 100644 --- a/cmd/internal/config/config_test.go +++ b/cmd/internal/config/config_test.go @@ -1,6 +1,9 @@ package config import ( + "os" + "path/filepath" + "strings" "testing" "time" @@ -10,7 +13,7 @@ import ( func TestLoadParsesSeedPolicy(t *testing.T) { v := viper.New() v.Set("server_url", "http://localhost:5173") - v.Set("downloader.token", "token") + v.Set("token", "token") v.Set("downloader.seed.enabled", true) v.Set("downloader.seed.duration", "30m") v.Set("downloader.seed.cache_limit", "10GB") @@ -32,6 +35,9 @@ func TestLoadParsesSeedPolicy(t *testing.T) { if cfg.SeedRatio != 1.5 { t.Fatalf("expected seed ratio 1.5, got %f", cfg.SeedRatio) } + if cfg.Token != "token" { + t.Fatalf("expected global token to be loaded, got %q", cfg.Token) + } } func TestLoadUsesSafeSeedDefaults(t *testing.T) { @@ -101,7 +107,7 @@ func TestLoadRejectsInvalidSeedPolicy(t *testing.T) { t.Run(tt.name, func(t *testing.T) { v := viper.New() v.Set("server_url", "http://localhost:5173") - v.Set("downloader.token", "token") + v.Set("token", "token") v.Set(tt.key, tt.value) if _, err := Load(v); err == nil { @@ -110,3 +116,65 @@ func TestLoadRejectsInvalidSeedPolicy(t *testing.T) { }) } } + +func TestWriteDefaultConfigWritesCommentedRuntimeHints(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + if err := WriteDefaultConfig(path); err != nil { + t.Fatal(err) + } + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + text := string(content) + if strings.Contains(text, "token:") && !strings.Contains(text, "# token:") { + t.Fatalf("expected token to be commented in default config, got:\n%s", text) + } + if hasConfigLine(text, " aria2:") { + t.Fatalf("default config should not enable aria2 runtime block, got:\n%s", text) + } + if hasConfigLine(text, " qbittorrent:") { + t.Fatalf("default config should not enable qbittorrent runtime block, got:\n%s", text) + } + if !strings.Contains(text, " # url: \"ws://127.0.0.1:6800/jsonrpc\"") { + t.Fatalf("expected commented aria2 runtime hint, got:\n%s", text) + } +} + +func TestWriteConfigStoresGlobalTokenAndOmitsDefaultRuntimeBlocks(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + cfg := Config{ + ServerURL: "https://zpan.space", + Engine: "auto", + DownloadDir: "/downloads", + StateDir: "/state", + PollInterval: 5 * time.Second, + MaxConcurrentTasks: 2, + SeedEnabled: true, + SeedDuration: time.Hour, + SeedCacheLimit: 10_000_000_000, + } + if err := WriteConfig(path, cfg, "download-token"); err != nil { + t.Fatal(err) + } + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + text := string(content) + if !strings.Contains(text, "token: \"download-token\"") { + t.Fatalf("expected global token, got:\n%s", text) + } + if strings.Contains(text, "downloader:\n token:") || hasConfigLine(text, " aria2:") || hasConfigLine(text, " qbittorrent:") { + t.Fatalf("expected no downloader token or default runtime blocks, got:\n%s", text) + } +} + +func hasConfigLine(text string, line string) bool { + for _, candidate := range strings.Split(text, "\n") { + if candidate == line { + return true + } + } + return false +} diff --git a/cmd/zpan/main.go b/cmd/zpan/main.go index 05cab5c1..295a5e2a 100644 --- a/cmd/zpan/main.go +++ b/cmd/zpan/main.go @@ -6,7 +6,6 @@ import ( "log/slog" "os" "os/signal" - "path/filepath" "runtime" "strings" "syscall" @@ -101,7 +100,7 @@ func upCommand(v *viper.Viper, cfgFile *string) *cobra.Command { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() if cfg.Token == "" { - registered, err := registerDownloaderWithDeviceLogin(ctx, cmd, v, cfg, *cfgFile) + registered, err := registerDownloaderWithDeviceLogin(ctx, cmd, cfg, *cfgFile) if err != nil { return err } @@ -125,12 +124,7 @@ func configCommand(v *viper.Viper, cfgFile *string) *cobra.Command { Use: "init", Short: "Create a default config file", RunE: func(cmd *cobra.Command, args []string) error { - config.Defaults(v) - v.SetConfigFile(*cfgFile) - if err := os.MkdirAll(filepath.Dir(*cfgFile), 0o755); err != nil { - return err - } - return v.SafeWriteConfigAs(*cfgFile) + return config.WriteDefaultConfig(*cfgFile) }, }) return cmd @@ -139,7 +133,6 @@ func configCommand(v *viper.Viper, cfgFile *string) *cobra.Command { func registerDownloaderWithDeviceLogin( ctx context.Context, cmd *cobra.Command, - v *viper.Viper, cfg config.Config, cfgFile string, ) (client.CreateDownloaderResponse, error) { @@ -170,7 +163,7 @@ func registerDownloaderWithDeviceLogin( return client.CreateDownloaderResponse{}, err } slog.Info("downloader registered", "downloader_id", registered.Downloader.ID) - if err := saveRegisteredDownloaderConfig(v, cfg, cfgFile, registered.Token); err != nil { + if err := saveRegisteredDownloaderConfig(cfg, cfgFile, registered.Token); err != nil { return client.CreateDownloaderResponse{}, err } fmt.Fprintf(cmd.OutOrStdout(), "Downloader registered: %s\n", registered.Downloader.ID) @@ -178,16 +171,8 @@ func registerDownloaderWithDeviceLogin( return registered, nil } -func saveRegisteredDownloaderConfig(v *viper.Viper, cfg config.Config, cfgFile string, token string) error { - v.Set("server_url", cfg.ServerURL) - v.Set("downloader.token", token) - if err := os.MkdirAll(filepath.Dir(cfgFile), 0o755); err != nil { - return err - } - if _, err := os.Stat(cfgFile); err == nil { - return v.WriteConfigAs(cfgFile) - } - return v.SafeWriteConfigAs(cfgFile) +func saveRegisteredDownloaderConfig(cfg config.Config, cfgFile string, token string) error { + return config.WriteConfig(cfgFile, cfg, token) } func downloaderName() string {