Compare commits

..
8 Commits
36 changed files with 2048 additions and 207 deletions

No files matched your search

+1 -1
View File
@@ -508,7 +508,7 @@ jobs:
overwrite: true
build-ios:
if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'ios' }}
runs-on: macos-latest
runs-on: macos-14
needs: [get-release]
steps:
- uses: actions/checkout@v3
+13 -7
View File
@@ -53,7 +53,13 @@ jobs:
with:
go-version: ${{ env.GO_VERSION }}
- name: Run Unit tests.
run: go test -v -coverpkg=$(go list ./... | grep -v /cmd/ | grep -v /bind/ | paste -sd "," -) -covermode=count -coverprofile=coverage.txt $(go list ./... | grep -v /cmd/ | grep -v /bind/)
run: |
# Get packages for testing and coverage (including cmd/web for flags_test.go)
PACKAGES=$(go list ./... | grep -v /bind/ | grep -v -E '/cmd/(?!web)')
go test -v -coverpkg=$(echo "$PACKAGES" | paste -sd "," -) -covermode=count -coverprofile=coverage.txt $PACKAGES
# Filter out main.go from coverage report to avoid lowering coverage percentage
grep -v "main.go" coverage.txt > coverage_filtered.txt || true
mv coverage_filtered.txt coverage.txt
- uses: codecov/codecov-action@v4
with:
files: ./coverage.txt
@@ -63,8 +69,8 @@ jobs:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ windows-latest, macos-latest, ubuntu-22.04, ubuntu-22.04-arm ]
needs: [ test ]
os: [windows-latest, macos-latest, ubuntu-22.04, ubuntu-22.04-arm]
needs: [test]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
@@ -96,8 +102,8 @@ jobs:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ macos-latest, ubuntu-latest ]
needs: [ test ]
os: [macos-14, ubuntu-latest]
needs: [test]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
@@ -129,7 +135,7 @@ jobs:
build-web:
name: Build web check
runs-on: ubuntu-latest
needs: [ test ]
needs: [test]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
@@ -149,7 +155,7 @@ jobs:
build-docker:
name: Build docker check
runs-on: ubuntu-latest
needs: [ test ]
needs: [test]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
+170 -51
View File
@@ -3,19 +3,23 @@ package main
import (
"encoding/json"
"flag"
"fmt"
"github.com/GopeedLab/gopeed/pkg/base"
"os"
"path/filepath"
"reflect"
"strconv"
"strings"
"github.com/GopeedLab/gopeed/pkg/base"
)
type args struct {
Address *string `json:"address"`
Port *int `json:"port"`
Username *string `json:"username"`
Password *string `json:"password"`
ApiToken *string `json:"apiToken"`
StorageDir *string `json:"storageDir"`
Address *string `json:"address"`
Port *int `json:"port"`
Username *string `json:"username"`
Password *string `json:"password"`
ApiToken *string `json:"apiToken"`
StorageDir *string `json:"storageDir"`
WhiteDownloadDirs []string `json:"whiteDownloadDirs"`
// DownloadConfig when the first time to start the server, it will be configured as initial value
DownloadConfig *base.DownloaderStoreConfig `json:"downloadConfig"`
@@ -23,62 +27,177 @@ type args struct {
}
func parse() *args {
var cliArgs args
cliArgs.Address = flag.String("A", "0.0.0.0", "Bind Address")
cliArgs.Port = flag.Int("P", 9999, "Bind Port")
cliArgs.Username = flag.String("u", "gopeed", "HTTP Basic Auth Username")
cliArgs.Password = flag.String("p", "", "HTTP Basic Auth Pwd")
cliArgs.ApiToken = flag.String("T", "", "API token, it must be configured when using HTTP API in the case of enabling basic authentication")
cliArgs.StorageDir = flag.String("d", "", "Storage directory")
cliArgs.configPath = flag.String("c", "./config.json", "Config file path")
cfg := &args{}
cliConfig := loadCliArgs()
loadConfigFile(cfg, *cliConfig.configPath)
loadEnvVars(cfg)
// override with non-default command line arguments
overrideWithCliArgs(cfg, cliConfig)
// set default values for any unset fields
setDefaults(cfg)
return cfg
}
// loadCliArgs parses command line arguments and returns initial config
func loadCliArgs() *args {
cfg := &args{}
cfg.Address = flag.String("A", "", "Bind Address")
cfg.Port = flag.Int("P", 0, "Bind Port")
cfg.Username = flag.String("u", "", "Web Authentication Username")
cfg.Password = flag.String("p", "", "Web Authentication Password, if no password is set, web authentication will not be enabled")
cfg.ApiToken = flag.String("T", "", "API token, it must be configured when using HTTP API in the case of enabling web authentication")
cfg.StorageDir = flag.String("d", "", "Storage directory")
whiteDownloadDirs := flag.String("w", "", "White download directories, comma-separated")
cfg.configPath = flag.String("c", "./config.json", "Config file path")
flag.Parse()
// args priority: config file > cli args
cfgArgs := loadConfig(*cliArgs.configPath)
if cfgArgs.Address == nil {
cfgArgs.Address = cliArgs.Address
// Parse white download directories from comma-separated string
if whiteDownloadDirs != nil && *whiteDownloadDirs != "" {
dirs := strings.Split(*whiteDownloadDirs, ",")
for i := range dirs {
dirs[i] = strings.TrimSpace(dirs[i])
}
cfg.WhiteDownloadDirs = dirs
}
if cfgArgs.Port == nil {
cfgArgs.Port = cliArgs.Port
}
if cfgArgs.Username == nil {
cfgArgs.Username = cliArgs.Username
}
if cfgArgs.Password == nil {
cfgArgs.Password = cliArgs.Password
}
if cfgArgs.ApiToken == nil {
cfgArgs.ApiToken = cliArgs.ApiToken
}
if cfgArgs.StorageDir == nil {
cfgArgs.StorageDir = cliArgs.StorageDir
}
return cfgArgs
return cfg
}
func loadConfig(path string) *args {
var args args
// overrideWithCliArgs overrides config with non-empty command line arguments
func overrideWithCliArgs(cfg *args, cliConfig *args) {
// Only override if the cli value is not empty/zero
if cliConfig.Address != nil && *cliConfig.Address != "" {
cfg.Address = cliConfig.Address
}
if !filepath.IsAbs(path) {
if cliConfig.Port != nil && *cliConfig.Port != 0 {
cfg.Port = cliConfig.Port
}
if cliConfig.Username != nil && *cliConfig.Username != "" {
cfg.Username = cliConfig.Username
}
if cliConfig.Password != nil && *cliConfig.Password != "" {
cfg.Password = cliConfig.Password
}
if cliConfig.ApiToken != nil && *cliConfig.ApiToken != "" {
cfg.ApiToken = cliConfig.ApiToken
}
if cliConfig.StorageDir != nil && *cliConfig.StorageDir != "" {
cfg.StorageDir = cliConfig.StorageDir
}
if cliConfig.WhiteDownloadDirs != nil {
cfg.WhiteDownloadDirs = cliConfig.WhiteDownloadDirs
}
}
// setDefaults sets default values for any unset configuration fields
func setDefaults(cfg *args) {
if cfg.Address == nil {
address := "0.0.0.0"
cfg.Address = &address
}
if cfg.Port == nil {
port := 9999
cfg.Port = &port
}
if cfg.Username == nil {
username := "gopeed"
cfg.Username = &username
}
}
// loadConfigFile loads configuration from file
func loadConfigFile(cfg *args, configPath string) {
if !filepath.IsAbs(configPath) {
dir, err := os.Getwd()
if err != nil {
fmt.Println("config dir get failed, reason:" + err.Error())
return &args
return
}
path = filepath.Join(dir, path)
configPath = filepath.Join(dir, configPath)
}
file, err := os.ReadFile(path)
file, err := os.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
return &args
return
}
fmt.Println("config file read failed, reason:" + err.Error())
return &args
return
}
if err = json.Unmarshal(file, &args); err != nil {
fmt.Println("config file parse failed, reason:" + err.Error())
return &args
if err = json.Unmarshal(file, cfg); err != nil {
return
}
}
// loadEnvVars loads configuration from environment variables with prefix GOPEED_
func loadEnvVars(cfg *args) {
v := reflect.ValueOf(cfg).Elem()
t := reflect.TypeOf(cfg).Elem()
for i := 0; i < v.NumField(); i++ {
field := v.Field(i)
fieldType := t.Field(i)
// Get json tag as environment variable suffix
jsonTag := fieldType.Tag.Get("json")
if jsonTag == "" || jsonTag == "-" {
continue
}
// Remove options like omitempty
if commaIdx := strings.Index(jsonTag, ","); commaIdx != -1 {
jsonTag = jsonTag[:commaIdx]
}
// Convert to uppercase and add GOPEED_ prefix
envKey := "GOPEED_" + strings.ToUpper(jsonTag)
envValue := os.Getenv(envKey)
if envValue == "" {
continue
}
// Set value based on field type
if field.Kind() == reflect.Ptr {
if field.IsNil() {
// Create new pointer instance
newVal := reflect.New(field.Type().Elem())
field.Set(newVal)
}
switch field.Type().Elem().Kind() {
case reflect.String:
field.Elem().SetString(envValue)
case reflect.Int:
if intVal, err := strconv.Atoi(envValue); err == nil {
field.Elem().SetInt(int64(intVal))
}
default:
// For complex types like DownloadConfig, try JSON unmarshaling
if field.Type().Elem() == reflect.TypeOf(base.DownloaderStoreConfig{}) {
var config base.DownloaderStoreConfig
if err := json.Unmarshal([]byte(envValue), &config); err == nil {
field.Set(reflect.ValueOf(&config))
}
}
}
} else if field.Kind() == reflect.Slice {
// Handle non-pointer slice types (like []string for WhiteDownloadDirs)
if field.Type().Elem().Kind() == reflect.String {
dirs := strings.Split(envValue, ",")
for i := range dirs {
dirs[i] = strings.TrimSpace(dirs[i])
}
field.Set(reflect.ValueOf(dirs))
}
}
}
fmt.Printf("config file loaded: %s\n", path)
return &args
}
+698
View File
@@ -0,0 +1,698 @@
package main
import (
"os"
"path/filepath"
"reflect"
"testing"
"github.com/GopeedLab/gopeed/pkg/base"
)
func TestSetDefaults(t *testing.T) {
tests := []struct {
name string
input *args
expected *args
}{
{
name: "empty config should get defaults",
input: &args{},
expected: &args{
Address: stringPtr("0.0.0.0"),
Port: intPtr(9999),
Username: stringPtr("gopeed"),
},
},
{
name: "partial config should only fill missing defaults",
input: &args{
Address: stringPtr("127.0.0.1"),
},
expected: &args{
Address: stringPtr("127.0.0.1"),
Port: intPtr(9999),
Username: stringPtr("gopeed"),
},
},
{
name: "full config should remain unchanged",
input: &args{
Address: stringPtr("192.168.1.1"),
Port: intPtr(8080),
Username: stringPtr("admin"),
},
expected: &args{
Address: stringPtr("192.168.1.1"),
Port: intPtr(8080),
Username: stringPtr("admin"),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
setDefaults(tt.input)
if !reflect.DeepEqual(tt.input, tt.expected) {
t.Errorf("setDefaults() = %+v, want %+v", tt.input, tt.expected)
}
})
}
}
func TestOverrideWithCliArgs(t *testing.T) {
tests := []struct {
name string
config *args
cliConfig *args
expected *args
}{
{
name: "empty cli should not override",
config: &args{Address: stringPtr("192.168.1.1")},
cliConfig: &args{
Address: stringPtr(""),
Port: intPtr(0),
},
expected: &args{Address: stringPtr("192.168.1.1")},
},
{
name: "non-empty cli should override",
config: &args{Address: stringPtr("192.168.1.1")},
cliConfig: &args{
Address: stringPtr("127.0.0.1"),
Port: intPtr(8080),
},
expected: &args{
Address: stringPtr("127.0.0.1"),
Port: intPtr(8080),
},
},
{
name: "all fields override test",
config: &args{
Address: stringPtr("old_address"),
Port: intPtr(1111),
Username: stringPtr("old_user"),
Password: stringPtr("old_pass"),
ApiToken: stringPtr("old_token"),
StorageDir: stringPtr("old_dir"),
},
cliConfig: &args{
Address: stringPtr("new_address"),
Port: intPtr(2222),
Username: stringPtr("new_user"),
Password: stringPtr("new_pass"),
ApiToken: stringPtr("new_token"),
StorageDir: stringPtr("new_dir"),
},
expected: &args{
Address: stringPtr("new_address"),
Port: intPtr(2222),
Username: stringPtr("new_user"),
Password: stringPtr("new_pass"),
ApiToken: stringPtr("new_token"),
StorageDir: stringPtr("new_dir"),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
overrideWithCliArgs(tt.config, tt.cliConfig)
if !reflect.DeepEqual(tt.config, tt.expected) {
t.Errorf("overrideWithCliArgs() = %+v, want %+v", tt.config, tt.expected)
}
})
}
}
func TestLoadConfigFile(t *testing.T) {
// Create temporary directory for test files
tempDir, err := os.MkdirTemp("", "flags_test")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
tests := []struct {
name string
configData string
fileName string
expected *args
}{
{
name: "valid config file",
configData: `{
"address": "192.168.1.100",
"port": 8080,
"username": "testuser",
"password": "testpass",
"apiToken": "testtoken",
"storageDir": "/test/storage",
"downloadConfig": {
"downloadDir": "/test/downloads",
"maxRunning": 10
}
}`,
fileName: "valid_config.json",
expected: &args{
Address: stringPtr("192.168.1.100"),
Port: intPtr(8080),
Username: stringPtr("testuser"),
Password: stringPtr("testpass"),
ApiToken: stringPtr("testtoken"),
StorageDir: stringPtr("/test/storage"),
DownloadConfig: &base.DownloaderStoreConfig{
DownloadDir: "/test/downloads",
MaxRunning: 10,
},
},
},
{
name: "partial config file",
configData: `{
"address": "10.0.0.1",
"port": 3000
}`,
fileName: "partial_config.json",
expected: &args{
Address: stringPtr("10.0.0.1"),
Port: intPtr(3000),
},
},
{
name: "invalid json should not panic",
configData: `{invalid json}`,
fileName: "invalid_config.json",
expected: &args{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create test config file
configPath := filepath.Join(tempDir, tt.fileName)
err := os.WriteFile(configPath, []byte(tt.configData), 0644)
if err != nil {
t.Fatal(err)
}
cfg := &args{}
loadConfigFile(cfg, configPath)
if !reflect.DeepEqual(cfg, tt.expected) {
t.Errorf("loadConfigFile() = %+v, want %+v", cfg, tt.expected)
}
})
}
// Test non-existent file
t.Run("non-existent file", func(t *testing.T) {
cfg := &args{}
loadConfigFile(cfg, "/non/existent/file.json")
expected := &args{}
if !reflect.DeepEqual(cfg, expected) {
t.Errorf("loadConfigFile() with non-existent file = %+v, want %+v", cfg, expected)
}
})
}
func TestLoadEnvVars(t *testing.T) {
// Save original environment
originalEnv := make(map[string]string)
envKeys := []string{
"GOPEED_ADDRESS", "GOPEED_PORT", "GOPEED_USERNAME",
"GOPEED_PASSWORD", "GOPEED_APITOKEN", "GOPEED_STORAGEDIR",
"GOPEED_DOWNLOADCONFIG", "GOPEED_WHITEDOWNLOADDIRS",
}
for _, key := range envKeys {
originalEnv[key] = os.Getenv(key)
}
// Clean up function
cleanup := func() {
for _, key := range envKeys {
if val, exists := originalEnv[key]; exists {
os.Setenv(key, val)
} else {
os.Unsetenv(key)
}
}
}
defer cleanup()
tests := []struct {
name string
envVars map[string]string
expected *args
}{
{
name: "all environment variables set",
envVars: map[string]string{
"GOPEED_ADDRESS": "env.example.com",
"GOPEED_PORT": "7777",
"GOPEED_USERNAME": "envuser",
"GOPEED_PASSWORD": "envpass",
"GOPEED_APITOKEN": "envtoken",
"GOPEED_STORAGEDIR": "/env/storage",
},
expected: &args{
Address: stringPtr("env.example.com"),
Port: intPtr(7777),
Username: stringPtr("envuser"),
Password: stringPtr("envpass"),
ApiToken: stringPtr("envtoken"),
StorageDir: stringPtr("/env/storage"),
},
},
{
name: "partial environment variables",
envVars: map[string]string{
"GOPEED_ADDRESS": "partial.example.com",
"GOPEED_PORT": "5555",
},
expected: &args{
Address: stringPtr("partial.example.com"),
Port: intPtr(5555),
},
},
{
name: "downloadConfig from environment",
envVars: map[string]string{
"GOPEED_DOWNLOADCONFIG": `{"downloadDir": "/env/downloads", "maxRunning": 15}`,
},
expected: &args{
DownloadConfig: &base.DownloaderStoreConfig{
DownloadDir: "/env/downloads",
MaxRunning: 15,
},
},
},
{
name: "invalid port should be ignored",
envVars: map[string]string{
"GOPEED_PORT": "invalid_port",
},
expected: &args{
Port: intPtr(0), // Invalid port creates pointer with 0 value
},
},
{
name: "invalid json for downloadConfig should be ignored",
envVars: map[string]string{
"GOPEED_DOWNLOADCONFIG": `{invalid json}`,
},
expected: &args{
DownloadConfig: &base.DownloaderStoreConfig{}, // Invalid JSON creates empty config
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Clear all environment variables first
for _, key := range envKeys {
os.Unsetenv(key)
}
// Set test environment variables
for key, value := range tt.envVars {
os.Setenv(key, value)
}
cfg := &args{}
loadEnvVars(cfg)
if !reflect.DeepEqual(cfg, tt.expected) {
t.Errorf("loadEnvVars() = %+v, want %+v", cfg, tt.expected)
}
})
}
}
func TestConfigPriority(t *testing.T) {
// Create temporary directory for test files
tempDir, err := os.MkdirTemp("", "flags_test_priority")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
// Create test config file
configData := `{
"address": "config.example.com",
"port": 6666,
"username": "configuser",
"password": "configpass"
}`
configPath := filepath.Join(tempDir, "test_config.json")
err = os.WriteFile(configPath, []byte(configData), 0644)
if err != nil {
t.Fatal(err)
}
// Save original environment
originalEnv := make(map[string]string)
envKeys := []string{"GOPEED_ADDRESS", "GOPEED_PORT", "GOPEED_USERNAME"}
for _, key := range envKeys {
originalEnv[key] = os.Getenv(key)
}
defer func() {
for _, key := range envKeys {
if val, exists := originalEnv[key]; exists {
os.Setenv(key, val)
} else {
os.Unsetenv(key)
}
}
}()
// Set environment variables
os.Setenv("GOPEED_ADDRESS", "env.example.com")
os.Setenv("GOPEED_PORT", "7777")
// Test configuration priority: CLI > ENV > Config File > Defaults
cfg := &args{}
// Load config file
loadConfigFile(cfg, configPath)
// Load environment variables (should override config file)
loadEnvVars(cfg)
// Simulate CLI args (should override env vars)
cliConfig := &args{
Address: stringPtr("cli.example.com"),
// Port not set in CLI, should remain from env
// Username not set in CLI or env, should remain from config file
}
overrideWithCliArgs(cfg, cliConfig)
// Set defaults for missing fields
setDefaults(cfg)
expected := &args{
Address: stringPtr("cli.example.com"), // From CLI (highest priority)
Port: intPtr(7777), // From ENV (overrides config file)
Username: stringPtr("configuser"), // From config file (env not set)
Password: stringPtr("configpass"), // From config file only
}
if !reflect.DeepEqual(cfg, expected) {
t.Errorf("Configuration priority test failed.\nGot: %+v\nWant: %+v", cfg, expected)
}
}
func TestCompleteConfigurationFlow(t *testing.T) {
// This test simulates a complete configuration loading scenario
// with all sources: defaults, config file, environment variables, and CLI args
tempDir, err := os.MkdirTemp("", "flags_test_complete")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
// Create comprehensive config file
configData := `{
"address": "config.host.com",
"port": 5000,
"username": "configuser",
"password": "configpass",
"apiToken": "configtoken",
"storageDir": "/config/storage",
"downloadConfig": {
"downloadDir": "/config/downloads",
"maxRunning": 8,
"protocolConfig": {"http": {"maxConnections": 16}},
"proxy": {
"enable": true,
"system": false,
"scheme": "http",
"host": "proxy.example.com:8080"
}
}
}`
configPath := filepath.Join(tempDir, "complete_config.json")
err = os.WriteFile(configPath, []byte(configData), 0644)
if err != nil {
t.Fatal(err)
}
// Save and set environment variables
originalEnv := make(map[string]string)
envKeys := []string{"GOPEED_ADDRESS", "GOPEED_USERNAME", "GOPEED_APITOKEN", "GOPEED_WHITEDOWNLOADDIRS"}
for _, key := range envKeys {
originalEnv[key] = os.Getenv(key)
}
defer func() {
for _, key := range envKeys {
if val, exists := originalEnv[key]; exists {
os.Setenv(key, val)
} else {
os.Unsetenv(key)
}
}
}()
os.Setenv("GOPEED_ADDRESS", "env.host.com")
os.Setenv("GOPEED_USERNAME", "envuser")
// Simulate complete configuration loading
cfg := &args{}
// Load from config file
loadConfigFile(cfg, configPath)
// Override with environment variables
loadEnvVars(cfg)
// Override with CLI arguments
cliConfig := &args{
Port: intPtr(9090),
StorageDir: stringPtr("/cli/storage"),
}
overrideWithCliArgs(cfg, cliConfig)
// Set defaults
setDefaults(cfg)
// Verify the final configuration follows priority rules
if *cfg.Address != "env.host.com" {
t.Errorf("Address should be from environment, got %s", *cfg.Address)
}
if *cfg.Port != 9090 {
t.Errorf("Port should be from CLI, got %d", *cfg.Port)
}
if *cfg.Username != "envuser" {
t.Errorf("Username should be from environment, got %s", *cfg.Username)
}
if *cfg.Password != "configpass" {
t.Errorf("Password should be from config file, got %s", *cfg.Password)
}
if *cfg.ApiToken != "configtoken" {
t.Errorf("ApiToken should be from config file, got %s", *cfg.ApiToken)
}
if *cfg.StorageDir != "/cli/storage" {
t.Errorf("StorageDir should be from CLI, got %s", *cfg.StorageDir)
}
if cfg.DownloadConfig == nil {
t.Error("DownloadConfig should be loaded from config file")
} else {
if cfg.DownloadConfig.DownloadDir != "/config/downloads" {
t.Errorf("DownloadConfig.DownloadDir should be from config file, got %s", cfg.DownloadConfig.DownloadDir)
}
if cfg.DownloadConfig.MaxRunning != 8 {
t.Errorf("DownloadConfig.MaxRunning should be from config file, got %d", cfg.DownloadConfig.MaxRunning)
}
}
}
// Helper functions for creating pointers
func stringPtr(s string) *string {
return &s
}
func intPtr(i int) *int {
return &i
}
func TestWhiteDownloadDirs(t *testing.T) {
t.Run("overrideWithCliArgs should handle WhiteDownloadDirs", func(t *testing.T) {
tests := []struct {
name string
config *args
cliConfig *args
expected *args
}{
{
name: "nil cli WhiteDownloadDirs should not override",
config: &args{WhiteDownloadDirs: []string{"/old/dir1", "/old/dir2"}},
cliConfig: &args{
WhiteDownloadDirs: nil,
},
expected: &args{WhiteDownloadDirs: []string{"/old/dir1", "/old/dir2"}},
},
{
name: "non-nil cli WhiteDownloadDirs should override",
config: &args{WhiteDownloadDirs: []string{"/old/dir1"}},
cliConfig: &args{
WhiteDownloadDirs: []string{"/new/dir1", "/new/dir2"},
},
expected: &args{WhiteDownloadDirs: []string{"/new/dir1", "/new/dir2"}},
},
{
name: "empty cli WhiteDownloadDirs should override with empty slice",
config: &args{WhiteDownloadDirs: []string{"/old/dir1"}},
cliConfig: &args{
WhiteDownloadDirs: []string{},
},
expected: &args{WhiteDownloadDirs: []string{}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
overrideWithCliArgs(tt.config, tt.cliConfig)
if !reflect.DeepEqual(tt.config, tt.expected) {
t.Errorf("overrideWithCliArgs() = %+v, want %+v", tt.config, tt.expected)
}
})
}
})
t.Run("loadConfigFile should handle WhiteDownloadDirs", func(t *testing.T) {
// Create temporary directory for test files
tempDir, err := os.MkdirTemp("", "flags_test_whitedirs")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
tests := []struct {
name string
configData string
fileName string
expected *args
}{
{
name: "config with WhiteDownloadDirs array",
configData: `{
"address": "test.example.com",
"whiteDownloadDirs": ["/path/to/dir1", "/path/to/dir2", "/path/to/dir3"]
}`,
fileName: "whitedirs_config.json",
expected: &args{
Address: stringPtr("test.example.com"),
WhiteDownloadDirs: []string{"/path/to/dir1", "/path/to/dir2", "/path/to/dir3"},
},
},
{
name: "config with empty WhiteDownloadDirs array",
configData: `{
"address": "test.example.com",
"whiteDownloadDirs": []
}`,
fileName: "empty_whitedirs_config.json",
expected: &args{
Address: stringPtr("test.example.com"),
WhiteDownloadDirs: []string{},
},
},
{
name: "config without WhiteDownloadDirs",
configData: `{
"address": "test.example.com",
"port": 8080
}`,
fileName: "no_whitedirs_config.json",
expected: &args{
Address: stringPtr("test.example.com"),
Port: intPtr(8080),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create test config file
configPath := filepath.Join(tempDir, tt.fileName)
err := os.WriteFile(configPath, []byte(tt.configData), 0644)
if err != nil {
t.Fatal(err)
}
cfg := &args{}
loadConfigFile(cfg, configPath)
if !reflect.DeepEqual(cfg, tt.expected) {
t.Errorf("loadConfigFile() = %+v, want %+v", cfg, tt.expected)
}
})
}
})
t.Run("loadEnvVars should handle WhiteDownloadDirs", func(t *testing.T) {
// Save original environment
originalEnv := os.Getenv("GOPEED_WHITEDOWNLOADDIRS")
defer func() {
if originalEnv != "" {
os.Setenv("GOPEED_WHITEDOWNLOADDIRS", originalEnv)
} else {
os.Unsetenv("GOPEED_WHITEDOWNLOADDIRS")
}
}()
tests := []struct {
name string
envValue string
expected *args
}{
{
name: "comma-separated directories",
envValue: "/env/dir1,/env/dir2,/env/dir3",
expected: &args{
WhiteDownloadDirs: []string{"/env/dir1", "/env/dir2", "/env/dir3"},
},
},
{
name: "comma-separated with spaces",
envValue: " /env/dir1 , /env/dir2 , /env/dir3 ",
expected: &args{
WhiteDownloadDirs: []string{"/env/dir1", "/env/dir2", "/env/dir3"},
},
},
{
name: "single directory",
envValue: "/single/dir",
expected: &args{
WhiteDownloadDirs: []string{"/single/dir"},
},
},
{
name: "empty string should create empty slice",
envValue: "",
expected: &args{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Clear environment variable first
os.Unsetenv("GOPEED_WHITEDOWNLOADDIRS")
if tt.envValue != "" {
os.Setenv("GOPEED_WHITEDOWNLOADDIRS", tt.envValue)
}
cfg := &args{}
loadEnvVars(cfg)
if !reflect.DeepEqual(cfg, tt.expected) {
t.Errorf("loadEnvVars() = %+v, want %+v", cfg, tt.expected)
}
})
}
})
}
+19 -17
View File
@@ -6,11 +6,12 @@ package main
import (
"embed"
"fmt"
"github.com/GopeedLab/gopeed/cmd"
"github.com/GopeedLab/gopeed/pkg/rest/model"
"io/fs"
"os"
"path/filepath"
"github.com/GopeedLab/gopeed/cmd"
"github.com/GopeedLab/gopeed/pkg/rest/model"
)
//go:embed dist/*
@@ -23,36 +24,37 @@ func main() {
}
args := parse()
var webBasicAuth *model.WebBasicAuth
var webAuth *model.WebAuth
if isNotBlank(args.Username) && isNotBlank(args.Password) {
webBasicAuth = &model.WebBasicAuth{
webAuth = &model.WebAuth{
Username: *args.Username,
Password: *args.Password,
}
}
var dir string
var storageDir string
if args.StorageDir != nil && *args.StorageDir != "" {
dir = *args.StorageDir
storageDir = *args.StorageDir
} else {
exe, err := os.Executable()
if err != nil {
panic(err)
}
dir = filepath.Dir(exe)
storageDir = filepath.Join(filepath.Dir(exe), "storage")
}
cfg := &model.StartConfig{
Network: "tcp",
Address: fmt.Sprintf("%s:%d", *args.Address, *args.Port),
Storage: model.StorageBolt,
StorageDir: filepath.Join(dir, "storage"),
ApiToken: *args.ApiToken,
DownloadConfig: args.DownloadConfig,
ProductionMode: true,
WebEnable: true,
WebFS: sub,
WebBasicAuth: webBasicAuth,
Network: "tcp",
Address: fmt.Sprintf("%s:%d", *args.Address, *args.Port),
Storage: model.StorageBolt,
StorageDir: storageDir,
WhiteDownloadDirs: args.WhiteDownloadDirs,
ApiToken: *args.ApiToken,
DownloadConfig: args.DownloadConfig,
ProductionMode: true,
WebEnable: true,
WebFS: sub,
WebAuth: webAuth,
}
cmd.Start(cfg)
}
+22 -9
View File
@@ -13,6 +13,7 @@ require (
github.com/go-git/go-git/v5 v5.8.1
github.com/gorilla/handlers v1.5.1
github.com/gorilla/mux v1.8.0
github.com/imroc/req/v3 v3.52.2
github.com/matoous/go-nanoid/v2 v2.0.0
github.com/mattn/go-ieproxy v0.0.12
github.com/pkg/errors v0.9.1
@@ -21,17 +22,29 @@ require (
github.com/xiaoqidun/setft v0.0.0-20220310121541-be86327699ad
go.etcd.io/bbolt v1.3.11
golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f
golang.org/x/sync v0.9.0
golang.org/x/sync v0.13.0
)
require (
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/ebitengine/purego v0.8.1 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/icholy/digest v1.1.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/lufia/plan9stats v0.0.0-20220913051719-115f729f3c8c // indirect
github.com/onsi/ginkgo/v2 v2.23.4 // indirect
github.com/power-devops/perfstat v0.0.0-20220216144756-c35f1ee13d7c // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.51.0 // indirect
github.com/refraction-networking/utls v1.6.7 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.uber.org/automaxprocs v1.6.0 // indirect
go.uber.org/mock v0.5.1 // indirect
)
require (
@@ -61,7 +74,7 @@ require (
github.com/bits-and-blooms/bitset v1.15.0 // indirect
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 // indirect
github.com/cespare/xxhash v1.1.0 // indirect
github.com/cloudflare/circl v1.3.3 // indirect
github.com/cloudflare/circl v1.6.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dlclark/regexp2 v1.11.4 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
@@ -77,7 +90,7 @@ require (
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/google/btree v1.1.3 // indirect
github.com/google/pprof v0.0.0-20240910150728-a0b0bb1d4134 // indirect
github.com/google/pprof v0.0.0-20250423184734-337e5dd93bb4 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/huandu/xstrings v1.5.0 // indirect
@@ -122,13 +135,13 @@ require (
go.opentelemetry.io/otel v1.32.0 // indirect
go.opentelemetry.io/otel/metric v1.32.0 // indirect
go.opentelemetry.io/otel/trace v1.32.0 // indirect
golang.org/x/crypto v0.29.0 // indirect
golang.org/x/mod v0.22.0 // indirect
golang.org/x/net v0.31.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.20.0 // indirect
golang.org/x/crypto v0.37.0 // indirect
golang.org/x/mod v0.24.0 // indirect
golang.org/x/net v0.39.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/text v0.24.0 // indirect
golang.org/x/time v0.8.0 // indirect
golang.org/x/tools v0.27.0 // indirect
golang.org/x/tools v0.32.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
lukechampine.com/blake3 v1.3.0 // indirect
modernc.org/libc v1.61.0 // indirect
+61 -24
View File
@@ -94,6 +94,8 @@ github.com/anacrolix/upnp v0.1.4 h1:+2t2KA6QOhm/49zeNyeVwDu1ZYS9dB9wfxyVvh/wk7U=
github.com/anacrolix/upnp v0.1.4/go.mod h1:Qyhbqo69gwNWvEk1xNTXsS5j7hMHef9hdr984+9fIic=
github.com/anacrolix/utp v0.2.0 h1:65Cdmr6q9WSw2KsM+rtJFu7rqDzLl2bdysf4KlNPcFI=
github.com/anacrolix/utp v0.2.0/go.mod h1:HGk4GYQw1O/3T1+yhqT/F6EcBd+AAwlo9dYErNy7mj8=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=
@@ -122,8 +124,9 @@ github.com/chzyer/logex v1.2.0/go.mod h1:9+9sk7u7pGNWYMkh0hdiL++6OeibzJccyQU4p4M
github.com/chzyer/readline v1.5.0/go.mod h1:x22KAscuvRqlLoK9CsoYsmxoXZMMFVyOl86cAH8qUic=
github.com/chzyer/test v0.0.0-20210722231415-061457976a23/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cloudflare/circl v1.3.3 h1:fE/Qz0QdIGqeWfnwq0RE0R7MI51s0M2E4Ga9kq5AEMs=
github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -196,6 +199,8 @@ github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZs
github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
@@ -229,12 +234,12 @@ github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/pprof v0.0.0-20240910150728-a0b0bb1d4134 h1:c5FlPPgxOn7kJz3VoPLkQYQXGBS3EklQ4Zfi57uOuqQ=
github.com/google/pprof v0.0.0-20240910150728-a0b0bb1d4134/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
github.com/google/pprof v0.0.0-20250423184734-337e5dd93bb4 h1:gD0vax+4I+mAj+jEChEf25Ia07Jq7kYOFO5PPhAxFl4=
github.com/google/pprof v0.0.0-20250423184734-337e5dd93bb4/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -250,6 +255,11 @@ github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
@@ -262,6 +272,10 @@ github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq
github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/ianlancetaylor/demangle v0.0.0-20220319035150-800ac71e25c2/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w=
github.com/icholy/digest v1.1.0 h1:HfGg9Irj7i+IX1o1QAmPfIBNu/Q5A5Tu3n/MED9k9H4=
github.com/icholy/digest v1.1.0/go.mod h1:QNrsSGQ5v7v9cReDI0+eyjsXGUoRSUZQHeQ5C4XLa0Y=
github.com/imroc/req/v3 v3.52.2 h1:xJocr1aIv0a2K9knfBQ4JnZHk+kWTITdjf0mgDg229I=
github.com/imroc/req/v3 v3.52.2/go.mod h1:dBGsDloOSZJcFs6PnTjZXYBJK70OXbZpizHBLNqcH2k=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
@@ -277,6 +291,8 @@ github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:C
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
@@ -331,7 +347,11 @@ github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJm
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus=
github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8=
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/onsi/gomega v1.36.3 h1:hID7cr8t3Wp26+cYnfcjR6HpJ00fdogN6dqZ1t6IylU=
github.com/onsi/gomega v1.36.3/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw=
github.com/philhofer/fwd v1.0.0/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU=
github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
@@ -380,6 +400,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20220216144756-c35f1ee13d7c h1:NRoLoZvkBTKvR5gQLgA3e0hqjkY9u1wm+iOL45VN/qI=
github.com/power-devops/perfstat v0.0.0-20220216144756-c35f1ee13d7c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
@@ -404,7 +426,13 @@ github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4O
github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/protolambda/ctxlock v0.1.0 h1:rCUY3+vRdcdZXqT07iXgyr744J2DU2LCBIXowYAjBCE=
github.com/protolambda/ctxlock v0.1.0/go.mod h1:vefhX6rIZH8rsg5ZpOJfEDYQOppZi19SfPiGOFrNnwM=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.51.0 h1:K8exxe9zXxeRKxaXxi/GpUqYiTrtdiWP8bo1KFya6Wc=
github.com/quic-go/quic-go v0.51.0/go.mod h1:MFlGGpcpJqRAfmYi6NC2cptDPSxRWTOGNuP4wqrWmzQ=
github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/refraction-networking/utls v1.6.7 h1:zVJ7sP1dJx/WtVuITug3qYUq034cDq9B2MR1K67ULZM=
github.com/refraction-networking/utls v1.6.7/go.mod h1:BC3O4vQzye5hqpmDTWUqi4P5DDhzJfkV1tdqtawQIH0=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
@@ -452,8 +480,9 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tidwall/btree v1.7.0 h1:L1fkJH/AuEh5zBnnBbmTwQ5Lt+bRJ5A8EWecslvo9iI=
github.com/tidwall/btree v1.7.0/go.mod h1:twD9XRA5jj9VUQGELzDO4HPQTNJsoWWfYEL+EUQ2cKY=
github.com/tinylib/msgp v1.0.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
@@ -471,6 +500,8 @@ github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
github.com/xiaoqidun/setft v0.0.0-20220310121541-be86327699ad h1:QtuWk6dsrNXc/ugwCBEN0jG52q/4tXRzMvZm4fH6T9g=
github.com/xiaoqidun/setft v0.0.0-20220310121541-be86327699ad/go.mod h1:Jj8p9bgKGTPQ+M8CdUMS9p7Mmdoxa3OAcAjJQBu0CcI=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
@@ -487,8 +518,12 @@ go.opentelemetry.io/otel/metric v1.32.0 h1:xV2umtmNcThh2/a/aCP+h64Xx5wsj8qqnkYZk
go.opentelemetry.io/otel/metric v1.32.0/go.mod h1:jH7CIbbK6SH2V2wE16W05BHCtIDzauciCRLoc/SyMv8=
go.opentelemetry.io/otel/trace v1.32.0 h1:WIC9mYrXf8TmY/EXuULKc8hR17vE+Hjv2cssQDe03fM=
go.opentelemetry.io/otel/trace v1.32.0/go.mod h1:+i4rkvCraA+tG6AzwloGaCtkx53Fa+L+V8e9a7YvhT8=
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.5.1 h1:ASgazW/qBmR+A32MYFDB6E2POoTgOwT509VP0CT/fjs=
go.uber.org/mock v0.5.1/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
@@ -497,8 +532,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ=
golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20220428152302-39d4317da171/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE=
golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f h1:XdNn9LlyWAhLVp6P/i8QYBW+hlyhrhei9uErw2B5GJo=
@@ -512,8 +547,8 @@ golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
golang.org/x/mod v0.6.0-dev.0.20211013180041-c96bc1413d57/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -535,8 +570,8 @@ golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo=
golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -550,8 +585,8 @@ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ=
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -590,17 +625,15 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.26.0 h1:WEQa6V3Gja/BhNxg540hBip/kkaYtRg3cxg4oXSw4AU=
golang.org/x/term v0.26.0/go.mod h1:Si5m1o57C5nBNQo5z1iq+XDijt21BDBDp2bK0QI8e3E=
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -609,8 +642,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -624,8 +657,8 @@ golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4f
golang.org/x/tools v0.1.8-0.20211029000441-d6a9af8af023/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.27.0 h1:qEKojBykQkQ4EynWy4S8Weg69NumxKdn40Fce3uc/8o=
golang.org/x/tools v0.27.0/go.mod h1:sUi0ZgbwW9ZPAq26Ekut+weQPR5eIM6GQLQ1Yjm1H0Q=
golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU=
golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -646,6 +679,8 @@ google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miE
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -667,6 +702,8 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
lukechampine.com/blake3 v1.3.0 h1:sJ3XhFINmHSrYCgl958hscfIa3bw8x4DqMP3u1YvoYE=
+2 -2
View File
@@ -540,7 +540,7 @@ func (f *Fetcher) buildRequest(ctx context.Context, req *base.Request) (httpReq
}
if len(extra.Header) > 0 {
for k, v := range extra.Header {
headers.Set(k, v)
headers.Set(k, strings.TrimSpace(v))
}
}
if extra.Body != "" {
@@ -548,7 +548,7 @@ func (f *Fetcher) buildRequest(ctx context.Context, req *base.Request) (httpReq
}
}
if _, ok := headers[base.HttpHeaderUserAgent]; !ok {
headers.Set(base.HttpHeaderUserAgent, f.config.UserAgent)
headers.Set(base.HttpHeaderUserAgent, strings.TrimSpace(f.config.UserAgent))
}
if ctx != nil {
+15
View File
@@ -105,6 +105,21 @@ func TestFetcher_ResolveWithHostHeader(t *testing.T) {
}
}
func TestFetcher_ResolveWithInvalidHeader(t *testing.T) {
fetcher := buildFetcher()
err := fetcher.Resolve(&base.Request{
URL: "https://bing.com",
Extra: &http.ReqExtra{
Header: map[string]string{
"Referer": "\rtest",
},
},
})
if err != nil {
t.Errorf("Resolve() got = %v, want nil", err)
}
}
func testResolve(startTestServer func() net.Listener, path string, want *base.Resource, t *testing.T) {
listener := startTestServer()
defer listener.Close()
+2 -2
View File
@@ -941,9 +941,9 @@ func (d *Downloader) doCreate(f fetcher.Fetcher, opts *base.Options) (taskId str
}
// if enable white download directory, check if the download directory is in the white list
if len(d.cfg.DownloadDirWhiteList) > 0 {
if len(d.cfg.WhiteDownloadDirs) > 0 {
inWhiteList := false
for _, dir := range d.cfg.DownloadDirWhiteList {
for _, dir := range d.cfg.WhiteDownloadDirs {
if match, err := filepath.Match(dir, opts.Path); match && err == nil {
inWhiteList = true
break
+1 -1
View File
@@ -91,7 +91,7 @@ func TestDownloader_CreateNotInWhite(t *testing.T) {
defer listener.Close()
downloader := NewDownloader(&DownloaderConfig{
DownloadDirWhiteList: []string{"./downloads"},
WhiteDownloadDirs: []string{"./downloads"},
})
if err := downloader.Setup(); err != nil {
t.Fatal(err)
+47
View File
@@ -205,6 +205,31 @@ function testTimeout(){
xhr.send();
});
}
async function testFingerprint(fingerprint,ua){
__gopeed_setFingerprint(fingerprint);
const resp = await fetch(host+'/ua');
const data = await resp.json();
if(!data.user_agent.includes(ua)){
throw new Error('fingerprint test failed, user agent: ' + data.user_agent);
}
}
async function testFingerprintDefault(){
await testFingerprint('none', 'Go')
}
async function testFingerprintChrome(){
await testFingerprint('chrome', 'Chrome')
}
async function testFingerprintFirefox(){
await testFingerprint('firefox', 'Firefox')
}
async function testFingerprintSafari(){
await testFingerprint('safari', 'Safari')
}
`)
if err != nil {
t.Fatal(err)
@@ -288,6 +313,19 @@ function testTimeout(){
if err == nil || err.Error() != "timeout" {
t.Fatalf("timeout test failed, want %s, got %s", "timeout", err)
}
_, err = callTestFun(engine, "testFingerprintChrome")
if err != nil {
t.Fatal("testFingerprintChrome test failed", err)
}
_, err = callTestFun(engine, "testFingerprintFirefox")
if err != nil {
t.Fatal("testFingerprintFirefox test failed", err)
}
_, err = callTestFun(engine, "testFingerprintSafari")
if err != nil {
t.Fatal("testFingerprintSafari test failed", err)
}
}
func TestFetchWithProxy(t *testing.T) {
@@ -470,6 +508,15 @@ func startServer() net.Listener {
w.Write([]byte("ok"))
}
})
mux.HandleFunc("/ua", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
data := map[string]any{
"user_agent": r.UserAgent(),
}
buf, _ := json.Marshal(data)
w.WriteHeader(http.StatusOK)
w.Write(buf)
})
server.Handler = mux
go server.Serve(listener)
return listener
+75 -51
View File
@@ -15,6 +15,7 @@ import (
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/formdata"
"github.com/GopeedLab/gopeed/pkg/download/engine/util"
"github.com/dop251/goja"
"github.com/imroc/req/v3"
)
const (
@@ -129,7 +130,7 @@ type XMLHttpRequest struct {
requestHeaders http.Header
responseHeaders http.Header
aborted bool
proxyHandler func(r *http.Request) (*url.URL, error)
client *req.Client
WithCredentials bool `json:"withCredentials"`
Upload *XMLHttpRequestUpload `json:"upload"`
@@ -163,25 +164,39 @@ func (xhr *XMLHttpRequest) SetRequestHeader(key, value string) {
}
func (xhr *XMLHttpRequest) Send(data goja.Value) {
var req *http.Request
var err error
setFingerprint(xhr.client)
d := xhr.parseData(data)
var (
contentType string
contentLength int64
isStringBody bool
)
if d == nil || xhr.method == "GET" || xhr.method == "HEAD" {
req, err = http.NewRequest(xhr.method, xhr.url, nil)
} else {
// Create request using req library
reqBuilder := xhr.client.R()
// Set headers first
if xhr.requestHeaders != nil {
for key, values := range xhr.requestHeaders {
if len(values) > 0 {
// Merge multiple values with comma separator (HTTP standard)
mergedValue := strings.Join(values, ", ")
reqBuilder.SetHeader(key, mergedValue)
}
}
}
// Handle request body
if d != nil && xhr.method != "GET" && xhr.method != "HEAD" {
switch v := d.(type) {
case string:
req, err = http.NewRequest(xhr.method, xhr.url, bytes.NewBufferString(v))
reqBuilder.SetBody(v)
contentType = "text/plain;charset=UTF-8"
contentLength = int64(len(v))
isStringBody = true
case *file.File:
req, err = http.NewRequest(xhr.method, xhr.url, v.Reader)
reqBuilder.SetBody(v.Reader)
contentType = "application/octet-stream"
contentLength = v.Size
case *formdata.FormData:
@@ -203,43 +218,38 @@ func (xhr *XMLHttpRequest) Send(data goja.Value) {
defer mw.Close()
mw.Send()
}()
req, err = http.NewRequest(xhr.method, xhr.url, pr)
reqBuilder.SetBody(pr)
contentType = mw.FormDataContentType()
contentLength = mw.Size()
}
}
if err != nil {
xhr.callOnerror()
return
}
req.Header = xhr.requestHeaders
// Only string body can specify Content-Type header by user
if contentType != "" && (!isStringBody || req.Header.Get("Content-Type") == "") {
req.Header.Set("Content-Type", contentType)
if contentType != "" && (!isStringBody || xhr.requestHeaders.Get("Content-Type") == "") {
reqBuilder.SetHeader("Content-Type", contentType)
}
if contentLength > 0 {
req.ContentLength = contentLength
// Set timeout
if xhr.Timeout > 0 {
xhr.client.SetTimeout(time.Duration(xhr.Timeout) * time.Millisecond)
}
transport := &http.Transport{
Proxy: xhr.proxyHandler,
}
client := &http.Client{
Transport: transport,
Timeout: time.Duration(xhr.Timeout) * time.Millisecond,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if xhr.Redirect == redirectManual {
return http.ErrUseLastResponse
}
if xhr.Redirect == redirectError {
return errors.New("redirect failed")
}
if len(via) > 20 {
return errors.New("too many redirects")
}
return nil
},
}
resp, err := client.Do(req)
// Configure redirect behavior
xhr.client.SetRedirectPolicy(func(req *http.Request, via []*http.Request) error {
if xhr.Redirect == redirectManual {
return http.ErrUseLastResponse
}
if xhr.Redirect == redirectError {
return errors.New("redirect failed")
}
if len(via) > 20 {
return errors.New("too many redirects")
}
return nil
})
// Execute request
resp, err := reqBuilder.Send(xhr.method, xhr.url)
if err != nil {
// handle timeout error
var ne net.Error
@@ -254,35 +264,37 @@ func (xhr *XMLHttpRequest) Send(data goja.Value) {
xhr.callOnerror()
return
}
defer resp.Body.Close()
xhr.Upload.callOnprogress(contentLength, contentLength)
if !xhr.aborted {
xhr.Upload.callOnload()
}
responseUrl := resp.Request.URL
responseUrl.Fragment = ""
xhr.ResponseUrl = responseUrl.String()
// Set response URL (final URL after redirects)
if resp.Response != nil && resp.Response.Request != nil && resp.Response.Request.URL != nil {
responseUrl := resp.Response.Request.URL
responseUrl.Fragment = ""
xhr.ResponseUrl = responseUrl.String()
} else {
xhr.ResponseUrl = xhr.url
}
// Set response headers
xhr.responseHeaders = resp.Header
xhr.Status = resp.StatusCode
xhr.StatusText = resp.Status
xhr.doReadystatechange(2)
buf, err := io.ReadAll(resp.Body)
if err != nil {
xhr.callOnerror()
return
}
bodyBytes := resp.Bytes()
xhr.doReadystatechange(3)
xhr.Response = string(buf)
xhr.Response = string(bodyBytes)
xhr.ResponseText = xhr.Response
xhr.doReadystatechange(4)
respBodyLen := int64(len(buf))
respBodyLen := int64(len(bodyBytes))
xhr.callOnprogress(respBodyLen, respBodyLen)
if !xhr.aborted {
xhr.callOnload()
}
return
}
func (xhr *XMLHttpRequest) Abort() {
@@ -293,6 +305,9 @@ func (xhr *XMLHttpRequest) Abort() {
}
func (xhr *XMLHttpRequest) GetResponseHeader(key string) string {
if xhr.responseHeaders == nil {
return ""
}
return strings.Join(xhr.responseHeaders.Values(key), ", ")
}
@@ -359,8 +374,14 @@ func Enable(runtime *goja.Runtime, proxyHandler func(r *http.Request) (*url.URL,
return instanceValue
})
xhr := runtime.ToValue(func(call goja.ConstructorCall) *goja.Object {
// Create req client with proxy support
client := req.C()
if proxyHandler != nil {
client.SetProxy(proxyHandler)
}
instance := &XMLHttpRequest{
proxyHandler: proxyHandler,
client: client,
Upload: &XMLHttpRequestUpload{
EventProp: &EventProp{
eventListeners: make(map[string]func(event *ProgressEvent)),
@@ -374,6 +395,9 @@ func Enable(runtime *goja.Runtime, proxyHandler func(r *http.Request) (*url.URL,
instanceValue.SetPrototype(call.This.Prototype())
return instanceValue
})
runtime.Set("__gopeed_setFingerprint", func(fingerprint Fingerprint) {
currentFingerprint = fingerprint
})
if err := runtime.Set("ProgressEvent", progressEvent); err != nil {
return err
}
@@ -0,0 +1,26 @@
package xhr
import (
"github.com/imroc/req/v3"
)
type Fingerprint string
const (
fingerprintChrome Fingerprint = "chrome"
fingerprintFirefox Fingerprint = "firefox"
fingerprintSafari Fingerprint = "safari"
)
var currentFingerprint Fingerprint = ""
func setFingerprint(client *req.Client) {
switch currentFingerprint {
case fingerprintChrome:
client.ImpersonateChrome()
case fingerprintFirefox:
client.ImpersonateFirefox()
case fingerprintSafari:
client.ImpersonateSafari()
}
}
+4 -4
View File
@@ -137,10 +137,10 @@ type DownloaderConfig struct {
Controller *controller.Controller
FetchManagers []fetcher.FetcherManager
RefreshInterval int `json:"refreshInterval"` // RefreshInterval time duration to refresh task progress(ms)
Storage Storage
StorageDir string
DownloadDirWhiteList []string `json:"downloadDirWhiteList"`
RefreshInterval int // RefreshInterval time duration to refresh task progress(ms)
Storage Storage
StorageDir string
WhiteDownloadDirs []string
ProductionMode bool
+2
View File
@@ -309,6 +309,8 @@ func DoProxy(w http.ResponseWriter, r *http.Request) {
r.RequestURI = ""
r.URL = targetUrl
r.Host = targetUrl.Host
r.Header.Del("Authorization")
r.Header.Del("X-Target-Uri")
resp, err := http.DefaultClient.Do(r)
if err != nil {
writeError(w, err.Error())
+4 -11
View File
@@ -1,7 +1,6 @@
package model
import (
"encoding/base64"
"github.com/GopeedLab/gopeed/pkg/base"
"io/fs"
)
@@ -25,9 +24,9 @@ type StartConfig struct {
ProductionMode bool
WebEnable bool
WebFS fs.FS
WebBasicAuth *WebBasicAuth
WebEnable bool
WebFS fs.FS
WebAuth *WebAuth
}
func (cfg *StartConfig) Init() *StartConfig {
@@ -49,13 +48,7 @@ func (cfg *StartConfig) Init() *StartConfig {
return cfg
}
type WebBasicAuth struct {
type WebAuth struct {
Username string
Password string
}
// Authorization returns the value of the Authorization header to be used in HTTP requests.
func (cfg *WebBasicAuth) Authorization() string {
userId := cfg.Username + ":" + cfg.Password
return "Basic " + base64.StdEncoding.EncodeToString([]byte(userId))
}
+126 -11
View File
@@ -2,6 +2,10 @@ package rest
import (
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"github.com/GopeedLab/gopeed/pkg/download"
@@ -10,12 +14,14 @@ import (
"github.com/gorilla/handlers"
"github.com/gorilla/mux"
"github.com/pkg/errors"
"io"
"io/fs"
"net"
"net/http"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
)
@@ -23,6 +29,7 @@ import (
var (
srv *http.Server
runningPort int
aesKey []byte
Downloader *download.Downloader
)
@@ -76,8 +83,9 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error
startCfg.Init()
downloadCfg := &download.DownloaderConfig{
ProductionMode: startCfg.ProductionMode,
RefreshInterval: startCfg.RefreshInterval,
ProductionMode: startCfg.ProductionMode,
RefreshInterval: startCfg.RefreshInterval,
WhiteDownloadDirs: startCfg.WhiteDownloadDirs,
}
if startCfg.Storage == model.StorageBolt {
downloadCfg.Storage = download.NewBoltStorage(startCfg.StorageDir)
@@ -95,6 +103,13 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error
util.SafeRemove(startCfg.Address)
}
if startCfg.WebEnable {
aesKey = make([]byte, 32)
if _, err := rand.Read(aesKey); err != nil {
return nil, nil, errors.Wrap(err, "generate aes key failed")
}
}
listener, err := net.Listen(startCfg.Network, startCfg.Address)
if err != nil {
return nil, nil, err
@@ -125,15 +140,40 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error
r.Methods(http.MethodGet).Path("/api/v1/extensions/{identity}/update").HandlerFunc(UpdateCheckExtension)
r.Methods(http.MethodPost).Path("/api/v1/extensions/{identity}/update").HandlerFunc(UpdateExtension)
r.Path("/api/v1/proxy").HandlerFunc(DoProxy)
enableApiToken := startCfg.ApiToken != ""
enableBasicAuth := startCfg.WebEnable && startCfg.WebAuth != nil
if startCfg.WebEnable {
if enableBasicAuth {
r.Methods(http.MethodPost).Path("/api/web/login").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var loginReq model.WebAuth
if ReadJson(r, w, &loginReq) {
if loginReq.Username == startCfg.WebAuth.Username && loginReq.Password == startCfg.WebAuth.Password {
// Generate a login token, Username:Password:Timestamp
timestamp := time.Now().Unix()
tokenData := fmt.Sprintf("%s:%s:%d", loginReq.Username, loginReq.Password, timestamp)
token, err := aesEncrypt(aesKey, []byte(tokenData))
if err != nil {
WriteJson(w, model.NewErrorResult(err.Error()))
return
}
WriteJson(w, model.NewOkResult(token))
return
}
}
WriteStatusJson(w, http.StatusUnauthorized, model.NewErrorResult("unauthorized", model.CodeUnauthorized))
})
}
r.PathPrefix("/fs/tasks").Handler(http.FileServer(new(taskFileSystem)))
r.PathPrefix("/fs/extensions").Handler(http.FileServer(new(extensionFileSystem)))
r.PathPrefix("/").Handler(gzipMiddleware(http.FileServer(newEmbedCacheFileSystem(http.FS(startCfg.WebFS)))))
}
enableApiToken := startCfg.ApiToken != ""
enableBasicAuth := startCfg.WebEnable && startCfg.WebBasicAuth != nil
if enableApiToken || enableBasicAuth {
writeUnauthorized := func(w http.ResponseWriter, r *http.Request) {
WriteStatusJson(w, http.StatusUnauthorized, model.NewErrorResult("unauthorized", model.CodeUnauthorized))
}
r.Use(func(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if enableApiToken {
@@ -145,19 +185,49 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error
return
}
WriteStatusJson(w, http.StatusUnauthorized, model.NewErrorResult("unauthorized", model.CodeUnauthorized))
writeUnauthorized(w, r)
return
}
}
if startCfg.WebEnable && startCfg.WebBasicAuth != nil {
if r.Header.Get("Authorization") == startCfg.WebBasicAuth.Authorization() {
if enableBasicAuth {
if r.URL.Path == "/api/web/login" {
h.ServeHTTP(w, r)
return
}
w.Header().Set("WWW-Authenticate", "Basic realm=\"gopeed web\"")
token := r.Header.Get("Authorization")
if token == "" {
writeUnauthorized(w, r)
return
}
token = strings.TrimPrefix(token, "Bearer ")
tokenData, err := aesDecrypt(aesKey, token)
if err != nil {
writeUnauthorized(w, r)
return
}
parts := strings.SplitN(string(tokenData), ":", 3)
username := parts[0]
password := parts[1]
timestamp, _ := strconv.Atoi(parts[2])
if username != startCfg.WebAuth.Username || password != startCfg.WebAuth.Password {
writeUnauthorized(w, r)
return
}
// Check if the token is expired (7 days)
if time.Now().Unix()-int64(timestamp) > 7*24*3600 {
writeUnauthorized(w, r)
return
}
h.ServeHTTP(w, r)
return
}
WriteStatusJson(w, http.StatusUnauthorized, model.NewErrorResult("unauthorized", model.CodeUnauthorized))
writeUnauthorized(w, r)
})
})
}
@@ -177,9 +247,10 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error
})
srv = &http.Server{Handler: handlers.CORS(
handlers.AllowedHeaders([]string{"Content-Type", "X-Api-Token", "X-Target-Uri"}),
handlers.AllowedHeaders([]string{"Content-Type", "Authorization", "X-Api-Token", "X-Target-Uri"}),
handlers.AllowedMethods([]string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}),
handlers.AllowedOrigins([]string{"*"}),
handlers.AllowCredentials(),
)(r)}
return srv, listener, nil
}
@@ -315,3 +386,47 @@ func WriteStatusJson(w http.ResponseWriter, statusCode int, v any) {
w.WriteHeader(statusCode)
json.NewEncoder(w).Encode(v)
}
func aesEncrypt(key, data []byte) (string, error) {
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
cipherText := gcm.Seal(nonce, nonce, data, nil)
return base64.StdEncoding.EncodeToString(cipherText), nil
}
func aesDecrypt(key []byte, encryptedData string) ([]byte, error) {
cipherText, err := base64.StdEncoding.DecodeString(encryptedData)
if err != nil {
return nil, err
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
if len(cipherText) < gcm.NonceSize() {
return nil, errors.New("ciphertext too short")
}
nonce, cipherText := cipherText[:gcm.NonceSize()], cipherText[gcm.NonceSize():]
return gcm.Open(nil, nonce, cipherText, nil)
}
+52 -5
View File
@@ -637,7 +637,7 @@ func TestAuthorization(t *testing.T) {
cfg.Init()
cfg.ApiToken = "123456"
cfg.WebEnable = true
cfg.WebBasicAuth = &model.WebBasicAuth{
cfg.WebAuth = &model.WebAuth{
Username: "admin",
Password: "123456",
}
@@ -649,14 +649,61 @@ func TestAuthorization(t *testing.T) {
Stop()
}()
status, _ := doHttpRequest0(http.MethodGet, "/api/v1/config", nil, nil)
status, _ := doHttpRequest0(http.MethodPost, "/api/web/login", nil, &model.WebAuth{
Username: "xxx",
Password: "xxx",
})
if status != http.StatusUnauthorized {
t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusUnauthorized)
}
token := httpRequestCheckOk[string](http.MethodPost, "/api/web/login", cfg.WebAuth)
authToken := fmt.Sprintf("Bearer %s", token)
authHeaders := map[string]string{
"Authorization": authToken,
}
status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", nil, nil)
if status != http.StatusUnauthorized {
t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusUnauthorized)
}
status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{
"Authorization": cfg.WebBasicAuth.Authorization(),
"Authorization": "xxx",
}, nil)
if status != http.StatusUnauthorized {
t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusUnauthorized)
}
status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{
"Authorization": "xxx",
}, nil)
if status != http.StatusUnauthorized {
t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusUnauthorized)
}
buildToken := func(username, password string, ts int64) string {
token, _ := aesEncrypt(aesKey, []byte(fmt.Sprintf("%s:%s:%d", username, password, ts)))
return token
}
fakeToken := buildToken("fake", "fake", time.Now().Unix())
status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{
"Authorization": fmt.Sprintf("Bearer %s", fakeToken),
}, nil)
if status != http.StatusUnauthorized {
t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusUnauthorized)
}
expireToken := buildToken(cfg.WebAuth.Username, cfg.WebAuth.Password, time.Now().Add(-time.Hour*8*24).Unix())
status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{
"Authorization": fmt.Sprintf("Bearer %s", expireToken),
}, nil)
if status != http.StatusUnauthorized {
t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusUnauthorized)
}
status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", authHeaders, nil)
if status != http.StatusOK {
t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusOK)
}
@@ -669,7 +716,7 @@ func TestAuthorization(t *testing.T) {
}
status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{
"Authorization": cfg.WebBasicAuth.Authorization(),
"Authorization": authToken,
"X-Api-Token": cfg.ApiToken,
}, nil)
if status != http.StatusOK {
@@ -677,7 +724,7 @@ func TestAuthorization(t *testing.T) {
}
status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{
"Authorization": cfg.WebBasicAuth.Authorization(),
"Authorization": authToken,
"X-Api-Token": "",
}, nil)
if status != http.StatusUnauthorized {
+37 -8
View File
@@ -3,13 +3,17 @@ import 'dart:io';
import 'package:dio/dio.dart';
import 'package:dio/io.dart';
import 'package:flutter/foundation.dart';
import 'package:get/get.dart' as getx;
import '../app/routes/app_pages.dart';
import '../database/database.dart';
import '../util/util.dart';
import 'model/create_task.dart';
import 'model/create_task_batch.dart';
import 'model/downloader_config.dart';
import 'model/extension.dart';
import 'model/install_extension.dart';
import 'model/login.dart';
import 'model/request.dart';
import 'model/resolve_result.dart';
import 'model/result.dart';
@@ -43,12 +47,27 @@ class _Client {
dio.options.sendTimeout = const Duration(seconds: 5);
dio.options.connectTimeout = const Duration(seconds: 5);
dio.options.receiveTimeout = const Duration(seconds: 60);
dio.interceptors.add(InterceptorsWrapper(onRequest: (options, handler) {
if (apiToken.isNotEmpty) {
options.headers['X-Api-Token'] = apiToken;
}
handler.next(options);
}));
dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) {
if (apiToken.isNotEmpty) {
options.headers['X-Api-Token'] = apiToken;
}
if (Util.isWeb()) {
final token = Database.instance.getWebToken();
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
}
handler.next(options);
},
onError: (error, handler) {
// Only web version has a login page
if (Util.isWeb() && error.response?.statusCode == 401) {
getx.Get.rootDelegate.offAndToNamed(Routes.LOGIN);
}
handler.next(error);
},
));
_instance!.dio = dio;
if (isUnixSocket) {
@@ -96,7 +115,8 @@ Future<T> _parse<T>(
} on DioException catch (e) {
if (e.type == DioExceptionType.sendTimeout ||
e.type == DioExceptionType.receiveTimeout ||
e.type == DioExceptionType.connectionTimeout) {
e.type == DioExceptionType.connectionTimeout ||
e.type == DioExceptionType.connectionError) {
throw TimeoutException("request timeout");
}
throw Exception(Result(code: 1000, msg: e.message));
@@ -216,6 +236,11 @@ Future<void> updateExtension(String identity) async {
() => _client.dio.post("api/v1/extensions/$identity/update"), null);
}
Future<String> login(LoginReq loginReq) async {
return _parse(() => _client.dio.post("api/web/login", data: loginReq),
(data) => data as String);
}
Future<Response<String>> proxyRequest<T>(String uri,
{data, Options? options}) async {
options ??= Options();
@@ -230,5 +255,9 @@ Future<Response<String>> proxyRequest<T>(String uri,
}
String join(String path) {
return "${_client.dio.options.baseUrl}/${Util.cleanPath(path)}";
final baseUrl = _client.dio.options.baseUrl;
final cleanBaseUrl = baseUrl.endsWith('/')
? baseUrl.substring(0, baseUrl.length - 1)
: baseUrl;
return "$cleanBaseUrl/${Util.cleanPath(path)}";
}
+18
View File
@@ -0,0 +1,18 @@
import 'package:json_annotation/json_annotation.dart';
part 'login.g.dart';
@JsonSerializable()
class LoginReq {
String username;
String password;
LoginReq({
required this.username,
required this.password,
});
factory LoginReq.fromJson(Map<String, dynamic> json) =>
_$LoginReqFromJson(json);
Map<String, dynamic> toJson() => _$LoginReqToJson(this);
}
+17
View File
@@ -0,0 +1,17 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'login.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
LoginReq _$LoginReqFromJson(Map<String, dynamic> json) => LoginReq(
username: json['username'] as String,
password: json['password'] as String,
);
Map<String, dynamic> _$LoginReqToJson(LoginReq instance) => <String, dynamic>{
'username': instance.username,
'password': instance.password,
};
@@ -16,6 +16,7 @@ import '../../../../api/model/extension.dart';
import '../../../../api/model/install_extension.dart';
import '../../../../api/model/switch_extension.dart';
import '../../../../api/model/update_extension_settings.dart';
import '../../../../database/database.dart';
import '../../../../util/message.dart';
import '../../../../util/util.dart';
import '../../../views/icon_button_loading.dart';
@@ -135,6 +136,10 @@ class ExtensionView extends GetView<ExtensionController> {
'/fs/extensions/${extension.identity}/${extension.icon}'),
width: 48,
height: 48,
headers: {
'Authorization':
'Bearer ${Database.instance.getWebToken()}'
},
)
: Image.file(
extension.devMode
@@ -0,0 +1,12 @@
import 'package:get/get.dart';
import '../controllers/login_controller.dart';
class LoginBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<LoginController>(
() => LoginController(),
);
}
}
@@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../../../api/api.dart' as api;
import '../../../../api/api.dart';
import '../../../../api/model/login.dart';
import '../../../../database/database.dart';
import '../../../../util/message.dart';
import '../../../routes/app_pages.dart';
import '../../app/controllers/app_controller.dart';
class LoginController extends GetxController {
final formKey = GlobalKey<FormState>();
final usernameController = TextEditingController();
final passwordController = TextEditingController();
final isLoading = false.obs;
final passwordVisible = false.obs;
@override
void onClose() {
usernameController.dispose();
passwordController.dispose();
super.onClose();
}
void togglePasswordVisibility() {
passwordVisible.value = !passwordVisible.value;
}
Future<void> login() async {
if (!formKey.currentState!.validate()) {
return;
}
isLoading.value = true;
try {
final loginReq = LoginReq(
username: usernameController.text.trim(),
password: passwordController.text,
);
final token = await api.login(loginReq);
// Login successful, save the token
Database.instance.saveWebToken(token);
// Reload config
final controller = Get.put(AppController());
await controller.loadDownloaderConfig();
// Navigate to home page
Get.rootDelegate.offAndToNamed(Routes.HOME);
} catch (e) {
if (e is TimeoutException) {
showMessage('error'.tr, 'login_failed_network'.tr);
} else {
showMessage('error'.tr, 'login_failed'.tr);
}
} finally {
isLoading.value = false;
}
}
}
@@ -0,0 +1,350 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:get/get.dart';
import '../../../views/responsive_builder.dart';
import '../controllers/login_controller.dart';
class LoginView extends GetView<LoginController> {
const LoginView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final isNarrow = ResponsiveBuilder.isNarrow(context);
return Scaffold(
body: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Get.theme.colorScheme.surface,
Get.theme.colorScheme.surface.withOpacity(0.8),
Get.theme.colorScheme.surface.withOpacity(0.9),
],
),
),
child: Center(
child: SingleChildScrollView(
padding: EdgeInsets.symmetric(
horizontal: isNarrow ? 16.0 : 32.0,
vertical: isNarrow ? 24.0 : 32.0,
),
child: Card(
elevation: 12,
shadowColor: Get.theme.colorScheme.shadow.withOpacity(0.3),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(isNarrow ? 20 : 24),
),
color: Get.theme.colorScheme.surface,
child: Container(
constraints: BoxConstraints(
maxWidth: isNarrow ? double.infinity : 420,
),
padding: EdgeInsets.all(isNarrow ? 24.0 : 40.0),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(isNarrow ? 20 : 24),
border: Border.all(
color: Get.theme.colorScheme.outline.withOpacity(0.1),
width: 1,
),
),
child: FocusTraversalGroup(
policy: OrderedTraversalPolicy(),
child: Form(
key: controller.formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Logo/Title Section
Container(
alignment: Alignment.center,
margin: EdgeInsets.only(bottom: isNarrow ? 32 : 48),
child: Column(
children: [
Container(
padding: EdgeInsets.all(isNarrow ? 8 : 12),
decoration: BoxDecoration(
borderRadius:
BorderRadius.circular(isNarrow ? 20 : 24),
boxShadow: [
BoxShadow(
color: Get.theme.colorScheme.primary
.withOpacity(0.2),
blurRadius: isNarrow ? 16 : 24,
offset: Offset(0, isNarrow ? 8 : 12),
spreadRadius: isNarrow ? 1 : 2,
),
],
),
child: ClipRRect(
borderRadius:
BorderRadius.circular(isNarrow ? 16 : 20),
child: SvgPicture.asset(
'assets/icon/icon.svg',
width: isNarrow ? 56 : 72,
height: isNarrow ? 56 : 72,
fit: BoxFit.cover,
),
),
),
const SizedBox(height: 8),
Text(
'Gopeed',
style: Get.textTheme.headlineLarge?.copyWith(
fontWeight: FontWeight.w900,
color: Get.theme.colorScheme.onSurface,
letterSpacing: 2.0,
fontSize: isNarrow ? 28 : 36,
),
),
],
),
),
// Username Field
FocusTraversalOrder(
order: const NumericFocusOrder(1.0),
child: TextFormField(
controller: controller.usernameController,
autofillHints: const [
AutofillHints.username,
],
autofocus: true,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => controller.login(),
decoration: InputDecoration(
labelText: 'username'.tr,
labelStyle: TextStyle(
color: Get.theme.colorScheme.onSurface
.withOpacity(0.7),
fontWeight: FontWeight.w500,
fontSize: 16,
),
prefixIcon: Container(
margin: const EdgeInsets.all(12),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Get.theme.colorScheme.primary
.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
Icons.person_outline_rounded,
color: Get.theme.colorScheme.primary,
size: 20,
),
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(18),
borderSide: BorderSide(
color: Get.theme.colorScheme.outline
.withOpacity(0.2),
width: 1,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(18),
borderSide: BorderSide(
color: Get.theme.colorScheme.outline
.withOpacity(0.3),
width: 1.5,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(18),
borderSide: BorderSide(
color: Get.theme.colorScheme.primary,
width: 2.5,
),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(18),
borderSide: BorderSide(
color: Get.theme.colorScheme.error,
width: 2,
),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(18),
borderSide: BorderSide(
color: Get.theme.colorScheme.error,
width: 2.5,
),
),
filled: true,
fillColor: Get
.theme.colorScheme.surfaceContainerHighest
.withOpacity(0.8),
contentPadding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 20,
),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'username_required'.tr;
}
return null;
},
),
),
SizedBox(height: isNarrow ? 16 : 24),
// Password Field
FocusTraversalOrder(
order: const NumericFocusOrder(2.0),
child: Obx(() => TextFormField(
controller: controller.passwordController,
autofillHints: const [
AutofillHints.password,
],
obscureText: !controller.passwordVisible.value,
decoration: InputDecoration(
labelText: 'password'.tr,
labelStyle: TextStyle(
color: Get.theme.colorScheme.onSurface
.withOpacity(0.7),
fontWeight: FontWeight.w500,
fontSize: 16,
),
prefixIcon: Container(
margin: const EdgeInsets.all(12),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Get.theme.colorScheme.primary
.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
Icons.lock_outline_rounded,
color: Get.theme.colorScheme.primary,
size: 20,
),
),
suffixIcon: IconButton(
icon: Icon(
controller.passwordVisible.value
? Icons.visibility_off_rounded
: Icons.visibility_rounded,
color: Get.theme.colorScheme.onSurface
.withOpacity(0.6),
),
onPressed:
controller.togglePasswordVisibility,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(18),
borderSide: BorderSide(
color: Get.theme.colorScheme.outline
.withOpacity(0.2),
width: 1,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(18),
borderSide: BorderSide(
color: Get.theme.colorScheme.outline
.withOpacity(0.3),
width: 1.5,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(18),
borderSide: BorderSide(
color: Get.theme.colorScheme.primary,
width: 2.5,
),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(18),
borderSide: BorderSide(
color: Get.theme.colorScheme.error,
width: 2,
),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(18),
borderSide: BorderSide(
color: Get.theme.colorScheme.error,
width: 2.5,
),
),
filled: true,
fillColor: Get
.theme.colorScheme.surfaceContainerHighest
.withOpacity(0.8),
contentPadding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 20,
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'password_required'.tr;
}
return null;
},
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => controller.login(),
)),
),
SizedBox(height: isNarrow ? 24 : 32),
// Login Button
Obx(() => ElevatedButton(
onPressed: controller.isLoading.value
? null
: controller.login,
style: ElevatedButton.styleFrom(
padding: EdgeInsets.symmetric(
vertical: isNarrow ? 16 : 18,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
backgroundColor: controller.isLoading.value
? Get.theme.disabledColor
: Get.theme.colorScheme.primary,
foregroundColor:
Get.theme.colorScheme.onPrimary,
elevation: controller.isLoading.value ? 0 : 4,
shadowColor: Get.theme.colorScheme.primary
.withOpacity(0.4),
),
child: controller.isLoading.value
? SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(
strokeWidth: 2.5,
valueColor:
AlwaysStoppedAnimation<Color>(Get
.theme.colorScheme.onPrimary),
),
)
: Text(
'login'.tr,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
),
),
)),
],
),
),
),
),
),
),
),
),
);
}
}
+8
View File
@@ -6,6 +6,8 @@ import '../modules/extension/bindings/extension_binding.dart';
import '../modules/extension/views/extension_view.dart';
import '../modules/home/bindings/home_binding.dart';
import '../modules/home/views/home_view.dart';
import '../modules/login/bindings/login_binding.dart';
import '../modules/login/views/login_view.dart';
import '../modules/redirect/bindings/redirect_binding.dart';
import '../modules/redirect/views/redirect_view.dart';
import '../modules/root/bindings/root_binding.dart';
@@ -63,6 +65,12 @@ class AppPages {
binding: SettingBinding(),
),
]),
GetPage(
name: _Paths.LOGIN,
page: () => const LoginView(),
binding: LoginBinding(),
transition: Transition.fadeIn,
),
GetPage(
name: _Paths.CREATE,
transition: Transition.downToUp,
@@ -7,6 +7,7 @@ abstract class Routes {
static const ROOT = _Paths.ROOT;
static const HOME = _Paths.HOME;
static const CREATE = _Paths.CREATE;
static const LOGIN = _Paths.LOGIN;
static const TASK = _Paths.HOME + _Paths.TASK;
static const TASK_FILES = TASK + _Paths.TASK_FILES;
static const EXTENSION = _Paths.HOME + _Paths.EXTENSION;
@@ -20,6 +21,7 @@ abstract class _Paths {
static const ROOT = '/';
static const HOME = '/home';
static const CREATE = '/create';
static const LOGIN = '/login';
static const TASK = '/task';
static const TASK_FILES = '/files';
static const EXTENSION = '/extension';
+9 -1
View File
@@ -6,10 +6,10 @@ import 'package:hive/hive.dart';
import 'entity.dart';
const String _startConfig = 'startConfig';
const String _lastRunningConfig = 'lastRunningConfig';
const String _windowState = 'windowState';
const String _bookmark = 'bookmark';
const String _createHistory = 'createHistory';
const String _webToken = 'webToken';
class Database {
static final Database _instance = Database._internal();
@@ -82,6 +82,14 @@ class Database {
});
}
void saveWebToken(String token) {
save<String>(_webToken, token);
}
String? getWebToken() {
return get<String>(_webToken, (json) => json.toString());
}
void saveCreateHistory(String url) {
var list = getCreateHistory() ?? [];
list.remove(url);
+129
View File
@@ -0,0 +1,129 @@
const deDE = {
'de_DE': {
'label': 'Deutsch',
'error': 'Fehler',
'tip': 'Tipp',
'confirm': 'Bestätigen',
'cancel': 'Abbrechen',
'on': 'An',
'off': 'Aus',
'selectAll': 'Alle Auswählen',
'select': 'Auswählen',
'task': 'Aufgaben',
'downloading': 'Wird Heruntergeladen',
'downloaded': 'Heruntergeladen',
'setting': 'Einstellungen',
'donate': 'Spenden',
'exit': 'Beenden',
'create': 'Aufgabe Erstellen',
'directDownload': 'Direkter Download',
'advancedOptions': 'Erweiterte Einstellungen',
'followSettings': 'Einstellungen Folgen',
'downloadLink': 'Download Link',
'downloadLinkValid': 'Bitte geben Sie den Download-Link ein',
'downloadLinkHit': 'Bitte geben Sie den Download-Link ein, einen pro Zeile@append',
'downloadLinkHitDesktop': ', oder ziehen Sie die Torrent-Datei direkt hierher',
'download': 'Download',
'noFileSelected': 'Bitte wählen Sie mindestens eine Datei aus, um fortzufahren.',
'noStoragePermission': 'Speicherberechtigung erforderlich',
'selectFile': 'Datei auswählen',
'rename': 'Umbenennen',
'basic': 'Basic',
'advanced': 'Fortschrittlich',
'general': 'Allgemein',
'downloadDir': 'Download-Verzeichnis',
'downloadDirValid': 'Bitte wählen Sie das Download-Verzeichnis',
'connections': 'Anschlüsse',
'useServerCtime': 'Serverzeit für die Dateierstellung verwenden',
'maxRunning': 'Maximale Anzahl ausgeführter Aufgaben',
'defaultDirectDownload': 'Direkten Download standardmäßig aktivieren',
'items': '@count Artikel',
'subscribeTracker': 'Abonnement-Tracker',
'subscribeFail':
'Anmeldung fehlgeschlagen. Bitte überprüfen Sie das Netzwerk oder versuchen Sie es später erneut.',
'update': 'Aktualisieren',
'updateDaily': 'Tägliche Aktualisierung',
'lastUpdate': 'Letzte Aktualisierung: @time',
'addTracker': 'Tracker hinzufügen',
'addTrackerHit': 'Bitte geben Sie die URL des Tracker-Servers ein, eine pro Zeile',
'ui': 'Benutzeroberfläche',
'theme': 'Thema',
'themeSystem': 'System',
'themeLight': 'Lichtmodus',
'themeDark': 'Dunkler Modus',
'locale': 'Sprache',
'about': 'Über',
'homepage': 'Homepage',
'version': 'Version',
'protocol': 'Protokoll',
'port': 'Port',
'apiToken': 'API Token',
'notSet': 'NS',
'set': 'SET',
'portInUse': 'Port [@port] wird verwendet, bitte ändern Sie den Port',
'effectAfterRestart': 'Wirkung nach Neustart',
'developer': 'Entwickler-in',
'logDirectory': 'Protokollverzeichnis',
'show': 'Zeigen',
'continue': 'Weitermachen',
'pause': 'Pause',
'startAll': 'Alles starten',
'pauseAll': 'Alle pausieren',
'deleteTask': '@count Aufgaben löschen',
'deleteTaskTip': 'Heruntergeladene Dateien behalten',
'delete': 'Löschen',
'newVersionTitle': 'Entdecken Sie die neue Version @version',
'newVersionUpdate': 'Jetzt aktualisieren',
'newVersionLater': 'Später',
'extensions': 'Erweiterungen',
'extensionInstallUrl': 'Installations-URL',
'extensionInstallSuccess': 'Erfolgreich installiert',
'extensionUpdateSuccess': 'Erfolgreich aktualisiert',
'extensionDelete': 'Erweiterung löschen',
'extensionAlreadyLatest': 'Es ist bereits die neueste Version',
'extensionFind': 'Erweiterungen suchen',
'extensionDevelop': 'Erweiterungen entwickeln',
'history': 'Verlauf',
'clearHistory': 'Verlauf löschen',
'noHistoryFound': 'Kein Verlauf gefunden',
'serviceTitle': 'Download-Service',
'serviceText': 'Läuft',
'network': 'Netzwerk',
'proxy': 'Proxy',
'noProxy': 'Kein Proxy',
'systemProxy': 'System-Proxy',
'customProxy': 'Benutzerdefinierter Proxy',
'server': 'Server',
'username': 'Benutzername',
'password': 'Passwort',
'thanks': 'Danke',
'thanksDesc':
'Vielen Dank an alle Mitwirkenden, die beim Aufbau und der Entwicklung der Gopeed-Community geholfen haben!',
'browserExtension': 'Browsererweiterung',
'launchAtStartup': 'Beim Start starten',
'seedConfig': 'Seed-Konfiguration',
'seedKeep': 'Weiter-seeden bis manuell gestoppt',
'seedRatio': 'Seed Verhältniss',
'seedTime': 'Seed-Zeit (Minuten)',
'setAsDefaultBtClient': 'Als Standard-BT-Client festlegen',
'taskDetail': 'Aufgabendetails',
'taskName': 'Aufgabenname',
'taskUrl': 'Aufgaben-URL',
'downloadPath': 'Download-Pfad',
'skipVerifyCert': 'Zertifikatsüberprüfung überspringen',
'name': 'Name',
'size': 'Größe',
'unknown': 'Unbekannt',
'fileSelectedCount': 'Dateien: ',
'fileSelectedSize': 'Größe: ',
'httpHeaderName': 'Header-Name',
'httpHeaderValue': 'Header-Wert',
'login': 'Login',
'username_required': 'Bitte geben Sie Ihren Benutzernamen ein',
'password_required': 'Bitte geben Sie Ihr Passwort ein',
'login_success': 'Anmeldung erfolgreich',
'login_failed': 'Anmeldung fehlgeschlagen, bitte überprüfen Sie Ihren Benutzernamen und Ihr Passwort',
'login_failed_network':
'Anmeldung fehlgeschlagen, bitte überprüfen Sie Ihre Netzwerkverbindung',
},
};
+7
View File
@@ -118,5 +118,12 @@ const enUS = {
'fileSelectedSize': 'Size: ',
'httpHeaderName': 'Header Name',
'httpHeaderValue': 'Header Value',
'login': 'Login',
'username_required': 'Please enter your username',
'password_required': 'Please enter your password',
'login_success': 'Login successful',
'login_failed': 'Login failed, please check your username and password',
'login_failed_network':
'Login failed, please check your network connection',
},
};
+6
View File
@@ -116,5 +116,11 @@ const zhCN = {
'fileSelectedSize': '大小:',
'httpHeaderName': '请求头名称',
'httpHeaderValue': '请求头值',
'login': '登录',
'username_required': '请输入用户名',
'password_required': '请输入密码',
'login_success': '登录成功',
'login_failed': '登录失败,请检查用户名和密码',
'login_failed_network': '登录失败,请检查网络连接',
}
};
+2
View File
@@ -1,5 +1,6 @@
import 'package:get/get.dart';
import 'langs/de_de.dart';
import 'langs/en_us.dart';
import 'langs/fa_ir.dart';
import 'langs/fr_fr.dart';
@@ -41,5 +42,6 @@ class _Messages extends Translations {
...ukUA,
...huHU,
...ptBR,
...deDE
};
}
+2 -1
View File
@@ -54,7 +54,8 @@ class Util {
static Future<void> initStorageDir() async {
var storageDir = "";
if (Util.isWindows()) {
storageDir = File(Platform.resolvedExecutable).parent.path;
storageDir =
path.join(File(Platform.resolvedExecutable).parent.path, "storage");
} else if (!Util.isWeb()) {
if (Util.isLinux()) {
storageDir = File(Platform.resolvedExecutable).parent.path;
+40
View File
@@ -448,6 +448,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.23"
flutter_svg:
dependency: "direct main"
description:
name: flutter_svg
sha256: c200fd79c918a40c5cd50ea0877fa13f81bdaf6f0a5d3dbcc2a13e3285d6aa1b
url: "https://pub.dev"
source: hosted
version: "2.0.17"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -803,6 +811,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.0"
path_parsing:
dependency: transitive
description:
name: path_parsing
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
path_provider:
dependency: "direct main"
description:
@@ -1329,6 +1345,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.5.1"
vector_graphics:
dependency: transitive
description:
name: vector_graphics
sha256: "44cc7104ff32563122a929e4620cf3efd584194eec6d1d913eb5ba593dbcf6de"
url: "https://pub.dev"
source: hosted
version: "1.1.18"
vector_graphics_codec:
dependency: transitive
description:
name: vector_graphics_codec
sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146"
url: "https://pub.dev"
source: hosted
version: "1.1.13"
vector_graphics_compiler:
dependency: transitive
description:
name: vector_graphics_compiler
sha256: "1b4b9e706a10294258727674a340ae0d6e64a7231980f9f9a3d12e4b42407aad"
url: "https://pub.dev"
source: hosted
version: "1.1.16"
vector_math:
dependency: transitive
description:
+3 -1
View File
@@ -15,7 +15,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
version: 1.7.1+1
version: 1.8.0+1
environment:
sdk: ">=3.0.0 <4.0.0"
@@ -77,6 +77,7 @@ dependencies:
git:
url: https://github.com/hui-z/flutter_install_plugin.git
ref: cf08af829f4a4145634f8a047108f505fdbe5eaa
flutter_svg: ^2.0.17
dependency_overrides:
permission_handler_windows:
git:
@@ -113,6 +114,7 @@ flutter:
assets:
- assets/tray_icon/
- assets/extension/
- assets/icon/
# Browser extension native host binary, see https://github.com/GopeedLab/gopeed/tree/main/cmd/host
- assets/exec/