diff --git a/CHANGELOG.md b/CHANGELOG.md index 16e7ff7a..dd6d649d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,26 @@ # Changelog -## [3.0.0-alpha.4] - Upcoming +## [3.0.0-beta.1] - 2026-01-13 + +### Security + +- Improved extension sandbox security + +### Changed + +- **Extension Manifest**: New `file` permission required for file operations + ```json + "permissions": { + "network": ["api.example.com"], + "storage": true, + "file": true + } + ``` + Extensions that need to download files must declare `"file": true` in manifest. + +--- + +## [3.0.0-alpha.4] - 2026-01-12 ### Added @@ -15,7 +35,7 @@ - **Custom URL Handler for Extensions**: Extensions can now register custom URL patterns - Handle URLs from YouTube Music, SoundCloud, Bandcamp, etc. - Manifest config: `urlHandler: { enabled: true, patterns: ["music.youtube.com"] }` - - Implement `handleURL(url)` function in extension to parse and return track metadata + - Implement `handleUrl(url)` function in extension to parse and return track metadata - SpotiFLAC automatically routes matching URLs to the appropriate extension - Supports share intents and paste from clipboard @@ -36,7 +56,7 @@ - Updated `docs/EXTENSION_DEVELOPMENT.md`: - Added Custom URL Handler section with examples - - Added `handleURL` function documentation + - Added `handleUrl` function documentation - Added URL pattern examples for YouTube, SoundCloud, Bandcamp - Added `utils.hmacSHA1` documentation with TOTP example diff --git a/go_backend/exports.go b/go_backend/exports.go index 30a07c80..0ec9c6b8 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -208,6 +208,11 @@ func DownloadTrack(requestJSON string) (string, error) { req.AlbumArtist = strings.TrimSpace(req.AlbumArtist) req.OutputDir = strings.TrimSpace(req.OutputDir) + // Add output directory to allowed download dirs for extensions + if req.OutputDir != "" { + AddAllowedDownloadDir(req.OutputDir) + } + var result DownloadResult var err error @@ -345,6 +350,11 @@ func DownloadWithFallback(requestJSON string) (string, error) { req.AlbumArtist = strings.TrimSpace(req.AlbumArtist) req.OutputDir = strings.TrimSpace(req.OutputDir) + // Add output directory to allowed download dirs for extensions + if req.OutputDir != "" { + AddAllowedDownloadDir(req.OutputDir) + } + // Build service order starting with preferred service allServices := []string{"tidal", "qobuz", "amazon"} preferredService := req.Service diff --git a/go_backend/extension_manifest.go b/go_backend/extension_manifest.go index 667ecce5..7a7a37f3 100644 --- a/go_backend/extension_manifest.go +++ b/go_backend/extension_manifest.go @@ -29,6 +29,7 @@ const ( type ExtensionPermissions struct { Network []string `json:"network"` // List of allowed domains Storage bool `json:"storage"` // Whether extension can use storage API + File bool `json:"file"` // Whether extension can use file API } // ExtensionSetting defines a configurable setting for an extension diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index b24c5645..e98aac57 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -7,6 +7,7 @@ import ( "path/filepath" "strings" "sync" + "time" "github.com/dop251/goja" ) @@ -140,8 +141,11 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe })() `, query, limit) - result, err := p.vm.RunString(script) + result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) if err != nil { + if IsTimeoutError(err) { + return nil, fmt.Errorf("searchTracks timeout: extension took too long to respond") + } return nil, fmt.Errorf("searchTracks failed: %w", err) } @@ -188,8 +192,11 @@ func (p *ExtensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata, })() `, trackID) - result, err := p.vm.RunString(script) + result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) if err != nil { + if IsTimeoutError(err) { + return nil, fmt.Errorf("getTrack timeout: extension took too long to respond") + } return nil, fmt.Errorf("getTrack failed: %w", err) } @@ -231,8 +238,11 @@ func (p *ExtensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata, })() `, albumID) - result, err := p.vm.RunString(script) + result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) if err != nil { + if IsTimeoutError(err) { + return nil, fmt.Errorf("getAlbum timeout: extension took too long to respond") + } return nil, fmt.Errorf("getAlbum failed: %w", err) } @@ -277,8 +287,11 @@ func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat })() `, artistID) - result, err := p.vm.RunString(script) + result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) if err != nil { + if IsTimeoutError(err) { + return nil, fmt.Errorf("getArtist timeout: extension took too long to respond") + } return nil, fmt.Errorf("getArtist failed: %w", err) } @@ -322,8 +335,11 @@ func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName })() `, isrc, trackName, artistName) - result, err := p.vm.RunString(script) + result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) if err != nil { + if IsTimeoutError(err) { + return nil, fmt.Errorf("checkAvailability timeout: extension took too long to respond") + } return nil, fmt.Errorf("checkAvailability failed: %w", err) } @@ -364,8 +380,11 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext })() `, trackID, quality) - result, err := p.vm.RunString(script) + result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) if err != nil { + if IsTimeoutError(err) { + return nil, fmt.Errorf("getDownloadUrl timeout: extension took too long to respond") + } return nil, fmt.Errorf("getDownloadUrl failed: %w", err) } @@ -387,6 +406,9 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext return &urlResult, nil } +// ExtDownloadTimeout is longer for extension download operations (5 minutes) +const ExtDownloadTimeout = 5 * time.Minute + // Download downloads a track with progress reporting func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string, onProgress func(percent int)) (*ExtDownloadResult, error) { if !p.extension.Manifest.IsDownloadProvider() { @@ -424,12 +446,19 @@ func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string, })() `, trackID, quality, outputPath) - result, err := p.vm.RunString(script) + // Use longer timeout for downloads (5 minutes) + result, err := RunWithTimeoutAndRecover(p.vm, script, ExtDownloadTimeout) if err != nil { + errMsg := err.Error() + errType := "script_error" + if IsTimeoutError(err) { + errMsg = "download timeout: extension took too long to complete" + errType = "timeout" + } return &ExtDownloadResult{ Success: false, - ErrorMessage: err.Error(), - ErrorType: "script_error", + ErrorMessage: errMsg, + ErrorType: errType, }, nil } @@ -947,8 +976,11 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string })() `, query, string(optionsJSON)) - result, err := p.vm.RunString(script) + result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) if err != nil { + if IsTimeoutError(err) { + return nil, fmt.Errorf("customSearch timeout: extension took too long to respond") + } return nil, fmt.Errorf("customSearch failed: %w", err) } @@ -1013,8 +1045,11 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e })() `, url) - result, err := p.vm.RunString(script) + result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) if err != nil { + if IsTimeoutError(err) { + return nil, fmt.Errorf("handleUrl timeout: extension took too long to respond") + } return nil, fmt.Errorf("handleUrl failed: %w", err) } @@ -1076,8 +1111,11 @@ func (p *ExtensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{} })() `, string(sourceJSON), string(candidatesJSON)) - result, err := p.vm.RunString(script) + result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) if err != nil { + if IsTimeoutError(err) { + return nil, fmt.Errorf("matchTrack timeout: extension took too long to respond") + } return nil, fmt.Errorf("matchTrack failed: %w", err) } @@ -1111,6 +1149,9 @@ type PostProcessResult struct { SampleRate int `json:"sample_rate,omitempty"` } +// PostProcessTimeout is longer for post-processing (2 minutes) +const PostProcessTimeout = 2 * time.Minute + // PostProcess runs post-processing hooks on a downloaded file func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[string]interface{}, hookID string) (*PostProcessResult, error) { if !p.extension.Manifest.HasPostProcessing() { @@ -1132,11 +1173,15 @@ func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[str })() `, filePath, string(metadataJSON), hookID) - result, err := p.vm.RunString(script) + result, err := RunWithTimeoutAndRecover(p.vm, script, PostProcessTimeout) if err != nil { + errMsg := err.Error() + if IsTimeoutError(err) { + errMsg = "postProcess timeout: extension took too long to complete" + } return &PostProcessResult{ Success: false, - Error: err.Error(), + Error: errMsg, }, nil } diff --git a/go_backend/extension_runtime.go b/go_backend/extension_runtime.go index 77c8c311..81a8db45 100644 --- a/go_backend/extension_runtime.go +++ b/go_backend/extension_runtime.go @@ -10,6 +10,9 @@ import ( "github.com/dop251/goja" ) +// Default timeout for JS execution (30 seconds) +const DefaultJSTimeout = 30 * time.Second + // Global auth state for extensions (stores pending auth codes) var ( extensionAuthState = make(map[string]*ExtensionAuthState) @@ -101,20 +104,88 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime { // Create a cookie jar for this extension jar, _ := newSimpleCookieJar() - client := &http.Client{ - Timeout: 30 * time.Second, - Jar: jar, - } - - return &ExtensionRuntime{ + runtime := &ExtensionRuntime{ extensionID: ext.ID, manifest: ext.Manifest, settings: make(map[string]interface{}), - httpClient: client, cookieJar: jar, dataDir: ext.DataDir, vm: ext.VM, } + + // Create HTTP client with redirect validation to prevent SSRF via open redirect + client := &http.Client{ + Timeout: 30 * time.Second, + Jar: jar, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + // Validate redirect target domain against allowed domains + domain := req.URL.Hostname() + if !ext.Manifest.IsDomainAllowed(domain) { + GoLog("[Extension:%s] Redirect blocked: domain '%s' not in allowed list\n", ext.ID, domain) + return &RedirectBlockedError{Domain: domain} + } + // Also block redirects to private/local networks (SSRF protection) + if isPrivateIP(domain) { + GoLog("[Extension:%s] Redirect blocked: private IP '%s'\n", ext.ID, domain) + return &RedirectBlockedError{Domain: domain, IsPrivate: true} + } + // Default redirect limit (10) + if len(via) >= 10 { + return http.ErrUseLastResponse + } + return nil + }, + } + runtime.httpClient = client + + return runtime +} + +// RedirectBlockedError is returned when a redirect is blocked due to domain validation +type RedirectBlockedError struct { + Domain string + IsPrivate bool +} + +func (e *RedirectBlockedError) Error() string { + if e.IsPrivate { + return "redirect blocked: private/local network access denied" + } + return "redirect blocked: domain '" + e.Domain + "' not in allowed list" +} + +// isPrivateIP checks if a hostname resolves to a private/local IP address +func isPrivateIP(host string) bool { + // Block common private network patterns + // This is a simple check - for production, consider DNS resolution + privatePatterns := []string{ + "localhost", + "127.", + "10.", + "172.16.", "172.17.", "172.18.", "172.19.", + "172.20.", "172.21.", "172.22.", "172.23.", + "172.24.", "172.25.", "172.26.", "172.27.", + "172.28.", "172.29.", "172.30.", "172.31.", + "192.168.", + "169.254.", // Link-local + "::1", // IPv6 localhost + "fc00:", // IPv6 private + "fe80:", // IPv6 link-local + } + + hostLower := host + for _, pattern := range privatePatterns { + if hostLower == pattern || len(hostLower) > len(pattern) && hostLower[:len(pattern)] == pattern { + return true + } + } + + // Also block .local domains + if len(host) > 6 && host[len(host)-6:] == ".local" { + return true + } + + return false } // simpleCookieJar is a simple in-memory cookie jar diff --git a/go_backend/extension_runtime_file.go b/go_backend/extension_runtime_file.go index 44b4ffd0..82ccec3b 100644 --- a/go_backend/extension_runtime_file.go +++ b/go_backend/extension_runtime_file.go @@ -8,25 +8,81 @@ import ( "os" "path/filepath" "strings" + "sync" "github.com/dop251/goja" ) // ==================== File API (Sandboxed) ==================== -// validatePath checks if the path is within the extension's data directory -// For absolute paths (from download queue), it allows them if they're valid +// List of allowed directories for file operations (set by Go backend for download operations) +var ( + allowedDownloadDirs []string + allowedDownloadDirsMu sync.RWMutex +) + +// SetAllowedDownloadDirs sets the list of directories where extensions can write files +// This should be called by the Go backend when setting up download paths +func SetAllowedDownloadDirs(dirs []string) { + allowedDownloadDirsMu.Lock() + defer allowedDownloadDirsMu.Unlock() + allowedDownloadDirs = dirs + GoLog("[Extension] Allowed download directories set: %v\n", dirs) +} + +// AddAllowedDownloadDir adds a directory to the allowed list +func AddAllowedDownloadDir(dir string) { + allowedDownloadDirsMu.Lock() + defer allowedDownloadDirsMu.Unlock() + absDir, err := filepath.Abs(dir) + if err == nil { + allowedDownloadDirs = append(allowedDownloadDirs, absDir) + } +} + +// isPathInAllowedDirs checks if an absolute path is within any allowed directory +func isPathInAllowedDirs(absPath string) bool { + allowedDownloadDirsMu.RLock() + defer allowedDownloadDirsMu.RUnlock() + + for _, allowedDir := range allowedDownloadDirs { + if strings.HasPrefix(absPath, allowedDir) { + return true + } + } + return false +} + +// validatePath checks if the path is within the extension's sandbox +// Security: Absolute paths are BLOCKED unless they're in allowed download directories +// Extensions should use relative paths for their own data storage func (r *ExtensionRuntime) validatePath(path string) (string, error) { + // Check if extension has file permission + if !r.manifest.Permissions.File { + return "", fmt.Errorf("file access denied: extension does not have 'file' permission") + } + // Clean and resolve the path cleanPath := filepath.Clean(path) - // If path is absolute, allow it (for download queue paths) - // This is safe because the Go backend controls what paths are passed + // SECURITY: Block absolute paths by default + // Only allow if path is in explicitly allowed download directories if filepath.IsAbs(cleanPath) { - return cleanPath, nil + absPath, err := filepath.Abs(cleanPath) + if err != nil { + return "", fmt.Errorf("invalid path: %w", err) + } + + // Check if path is in allowed download directories + if isPathInAllowedDirs(absPath) { + return absPath, nil + } + + // Block all other absolute paths + return "", fmt.Errorf("file access denied: absolute paths are not allowed. Use relative paths within extension sandbox") } - // For relative paths, join with data directory + // For relative paths, join with data directory (extension's sandbox) fullPath := filepath.Join(r.dataDir, cleanPath) // Resolve to absolute path @@ -35,7 +91,7 @@ func (r *ExtensionRuntime) validatePath(path string) (string, error) { return "", fmt.Errorf("invalid path: %w", err) } - // Ensure path is within data directory + // Ensure path is within data directory (prevent path traversal) absDataDir, _ := filepath.Abs(r.dataDir) if !strings.HasPrefix(absPath, absDataDir) { return "", fmt.Errorf("file access denied: path '%s' is outside sandbox", path) diff --git a/go_backend/extension_runtime_http.go b/go_backend/extension_runtime_http.go index c27bd7f2..61c7b36c 100644 --- a/go_backend/extension_runtime_http.go +++ b/go_backend/extension_runtime_http.go @@ -29,6 +29,12 @@ func (r *ExtensionRuntime) validateDomain(urlStr string) error { } domain := parsed.Hostname() + + // Block private/local network access (SSRF protection) + if isPrivateIP(domain) { + return fmt.Errorf("network access denied: private/local network '%s' not allowed", domain) + } + if !r.manifest.IsDomainAllowed(domain) { return fmt.Errorf("network access denied: domain '%s' not in allowed list", domain) } diff --git a/go_backend/extension_test.go b/go_backend/extension_test.go index 5cc552a1..4045279c 100644 --- a/go_backend/extension_test.go +++ b/go_backend/extension_test.go @@ -1,6 +1,7 @@ package gobackend import ( + "path/filepath" "testing" "github.com/dop251/goja" @@ -137,6 +138,9 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) { ID: "test-ext", Manifest: &ExtensionManifest{ Name: "test-ext", + Permissions: ExtensionPermissions{ + File: true, // Enable file permission for test + }, }, DataDir: tempDir, } @@ -166,6 +170,36 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) { if nestedPath == "" { t.Error("Expected non-empty nested path") } + + // Test absolute path should be blocked (security fix) + // Use platform-appropriate absolute path + var absPath string + if filepath.IsAbs("C:\\Windows\\System32") { + absPath = "C:\\Windows\\System32\\test.txt" // Windows + } else { + absPath = "/etc/passwd" // Unix + } + _, err = runtime.validatePath(absPath) + if err == nil { + t.Error("Expected absolute path to be blocked") + } + + // Test that extension without file permission is blocked + extNoFile := &LoadedExtension{ + ID: "test-ext-no-file", + Manifest: &ExtensionManifest{ + Name: "test-ext-no-file", + Permissions: ExtensionPermissions{ + File: false, // No file permission + }, + }, + DataDir: tempDir, + } + runtimeNoFile := NewExtensionRuntime(extNoFile) + _, err = runtimeNoFile.validatePath("test.txt") + if err == nil { + t.Error("Expected file access to be denied without file permission") + } } func TestExtensionRuntime_UtilityFunctions(t *testing.T) { @@ -217,3 +251,79 @@ func TestExtensionRuntime_UtilityFunctions(t *testing.T) { t.Error("Expected non-empty JSON string") } } + +func TestExtensionRuntime_SSRFProtection(t *testing.T) { + // Create extension with limited network permissions + ext := &LoadedExtension{ + ID: "test-ext", + Manifest: &ExtensionManifest{ + Name: "test-ext", + Permissions: ExtensionPermissions{ + Network: []string{"api.example.com"}, + }, + }, + DataDir: t.TempDir(), + } + + runtime := NewExtensionRuntime(ext) + + // Test that private IPs are blocked (SSRF protection) + privateIPs := []string{ + "http://localhost/admin", + "http://127.0.0.1/admin", + "http://192.168.1.1/admin", + "http://10.0.0.1/admin", + "http://172.16.0.1/admin", + "http://169.254.169.254/latest/meta-data/", // AWS metadata + "http://router.local/admin", + } + + for _, url := range privateIPs { + err := runtime.validateDomain(url) + if err == nil { + t.Errorf("Expected private IP/host '%s' to be blocked", url) + } + } + + // Test that allowed public domain still works + if err := runtime.validateDomain("https://api.example.com/path"); err != nil { + t.Errorf("Expected api.example.com to be allowed, got error: %v", err) + } +} + +func TestIsPrivateIP(t *testing.T) { + tests := []struct { + host string + expected bool + }{ + // Private IPs should be blocked + {"localhost", true}, + {"127.0.0.1", true}, + {"127.0.0.2", true}, + {"10.0.0.1", true}, + {"10.255.255.255", true}, + {"172.16.0.1", true}, + {"172.31.255.255", true}, + {"192.168.0.1", true}, + {"192.168.255.255", true}, + {"169.254.169.254", true}, // AWS metadata + {"router.local", true}, + {"mydevice.local", true}, + + // Public IPs should be allowed + {"8.8.8.8", false}, + {"1.1.1.1", false}, + {"api.example.com", false}, + {"google.com", false}, + {"172.15.0.1", false}, // Just outside 172.16-31 range + {"172.32.0.1", false}, // Just outside 172.16-31 range + {"192.167.0.1", false}, // Not 192.168.x.x + } + + for _, tt := range tests { + result := isPrivateIP(tt.host) + if result != tt.expected { + t.Errorf("isPrivateIP(%s) = %v, expected %v", tt.host, result, tt.expected) + } + } +} diff --git a/go_backend/extension_timeout.go b/go_backend/extension_timeout.go new file mode 100644 index 00000000..a55f0464 --- /dev/null +++ b/go_backend/extension_timeout.go @@ -0,0 +1,118 @@ +// Package gobackend provides timeout execution for extension JS code +package gobackend + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/dop251/goja" +) + +// JSExecutionError represents an error during JS execution +type JSExecutionError struct { + Message string + IsTimeout bool +} + +func (e *JSExecutionError) Error() string { + return e.Message +} + +// RunWithTimeout executes JavaScript code with a timeout +// Returns the result value and any error (including timeout) +func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) { + if timeout <= 0 { + timeout = DefaultJSTimeout + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + // Channel to receive result + type result struct { + value goja.Value + err error + } + resultCh := make(chan result, 1) + + // Track if we've interrupted + var interrupted bool + var interruptMu sync.Mutex + + // Run script in goroutine + go func() { + defer func() { + if r := recover(); r != nil { + // Check if this was our interrupt + interruptMu.Lock() + wasInterrupted := interrupted + interruptMu.Unlock() + + if wasInterrupted { + resultCh <- result{nil, &JSExecutionError{ + Message: "execution timeout exceeded", + IsTimeout: true, + }} + } else { + resultCh <- result{nil, fmt.Errorf("panic during execution: %v", r)} + } + } + }() + + val, err := vm.RunString(script) + resultCh <- result{val, err} + }() + + // Wait for result or timeout + select { + case res := <-resultCh: + return res.value, res.err + case <-ctx.Done(): + // Timeout - interrupt the VM + interruptMu.Lock() + interrupted = true + interruptMu.Unlock() + + vm.Interrupt("execution timeout") + + // Wait a bit for the goroutine to finish + select { + case res := <-resultCh: + // If we got a result after interrupt, it might be the timeout error + if res.err != nil { + return nil, res.err + } + return nil, &JSExecutionError{ + Message: "execution timeout exceeded", + IsTimeout: true, + } + case <-time.After(1 * time.Second): + // Force return timeout error + return nil, &JSExecutionError{ + Message: "execution timeout exceeded (force)", + IsTimeout: true, + } + } + } +} + +// RunWithTimeoutAndRecover runs JS with timeout and clears interrupt state after +// This should be used when you want to continue using the VM after a timeout +func RunWithTimeoutAndRecover(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) { + result, err := RunWithTimeout(vm, script, timeout) + + // Clear any interrupt state so VM can be reused + vm.ClearInterrupt() + + return result, err +} + +// IsTimeoutError checks if an error is a timeout error +func IsTimeoutError(err error) bool { + if jsErr, ok := err.(*JSExecutionError); ok { + return jsErr.IsTimeout + } + return false +} diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index b4611857..85644886 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -702,7 +702,7 @@ func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) ( Error string `json:"error"` } if json.Unmarshal(body, &errorResp) == nil && errorResp.Error != "" { - resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf(errorResp.Error), duration: time.Since(reqStart)} + resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("%s", errorResp.Error), duration: time.Since(reqStart)} return } diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index 189410ba..12129fa5 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -1,8 +1,8 @@ /// App version and info constants /// Update version here only - all other files will reference this class AppInfo { - static const String version = '3.0.0-alpha.4'; - static const String buildNumber = '53'; + static const String version = '3.0.0-beta.1'; + static const String buildNumber = '54'; static const String fullVersion = '$version+$buildNumber'; diff --git a/pubspec.yaml b/pubspec.yaml index 8d28b9d0..348ab27c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: "none" -version: 3.0.0-alpha.4+53 +version: 3.0.0-beta.1+54 environment: sdk: ^3.10.0 diff --git a/pubspec_ios.yaml b/pubspec_ios.yaml index fbee8eb3..a06e36bf 100644 --- a/pubspec_ios.yaml +++ b/pubspec_ios.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: "none" -version: 3.0.0-alpha.4+53 +version: 3.0.0-beta.1+54 environment: sdk: ^3.10.0