mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-30 17:50:07 +08:00
fix(cli): keep auth token in global config
This commit is contained in:
@@ -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 == "" {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+5
-20
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user