From f7d5a24d17be55bc6076e4e7d366ad1fb8c30f05 Mon Sep 17 00:00:00 2001 From: zarzet Date: Tue, 13 Jan 2026 04:17:00 +0700 Subject: [PATCH] refactor(extension): split extension_runtime.go into multiple files + add HMAC-SHA256 --- go_backend/extension_runtime.go | 2521 +-------------------- go_backend/extension_runtime_auth.go | 547 +++++ go_backend/extension_runtime_ffmpeg.go | 204 ++ go_backend/extension_runtime_file.go | 467 ++++ go_backend/extension_runtime_http.go | 499 ++++ go_backend/extension_runtime_matching.go | 151 ++ go_backend/extension_runtime_polyfills.go | 488 ++++ go_backend/extension_runtime_storage.go | 339 +++ go_backend/extension_runtime_utils.go | 313 +++ 9 files changed, 3018 insertions(+), 2511 deletions(-) create mode 100644 go_backend/extension_runtime_auth.go create mode 100644 go_backend/extension_runtime_ffmpeg.go create mode 100644 go_backend/extension_runtime_file.go create mode 100644 go_backend/extension_runtime_http.go create mode 100644 go_backend/extension_runtime_matching.go create mode 100644 go_backend/extension_runtime_polyfills.go create mode 100644 go_backend/extension_runtime_storage.go create mode 100644 go_backend/extension_runtime_utils.go diff --git a/go_backend/extension_runtime.go b/go_backend/extension_runtime.go index cc099e1..2162272 100644 --- a/go_backend/extension_runtime.go +++ b/go_backend/extension_runtime.go @@ -2,21 +2,8 @@ package gobackend import ( - "crypto/aes" - "crypto/cipher" - "crypto/md5" - "crypto/rand" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "encoding/json" - "fmt" - "io" "net/http" "net/url" - "os" - "path/filepath" - "strings" "sync" "time" @@ -37,6 +24,9 @@ type ExtensionAuthState struct { RefreshToken string ExpiresAt time.Time IsAuthenticated bool + // PKCE support + PKCEVerifier string + PKCEChallenge string } // PendingAuthRequest holds a pending OAuth request that needs Flutter to open URL @@ -195,6 +185,11 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) { authObj.Set("clearAuth", r.authClear) authObj.Set("isAuthenticated", r.authIsAuthenticated) authObj.Set("getTokens", r.authGetTokens) + // PKCE support + authObj.Set("generatePKCE", r.authGeneratePKCE) + authObj.Set("getPKCE", r.authGetPKCE) + authObj.Set("startOAuthWithPKCE", r.authStartOAuthWithPKCE) + authObj.Set("exchangeCodeWithPKCE", r.authExchangeCodeWithPKCE) vm.Set("auth", authObj) // File operations (sandboxed) @@ -229,6 +224,8 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) { utilsObj.Set("base64Decode", r.base64Decode) utilsObj.Set("md5", r.md5Hash) utilsObj.Set("sha256", r.sha256Hash) + utilsObj.Set("hmacSHA256", r.hmacSHA256) + utilsObj.Set("hmacSHA256Base64", r.hmacSHA256Base64) utilsObj.Set("parseJSON", r.parseJSON) utilsObj.Set("stringifyJSON", r.stringifyJSON) // Crypto utilities for developers @@ -269,2501 +266,3 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) { // JSON global (browser-compatible) r.registerJSONGlobal(vm) } - -// ==================== HTTP API (Sandboxed) ==================== - -// HTTPResponse represents the response from an HTTP request -type HTTPResponse struct { - StatusCode int `json:"statusCode"` - Body string `json:"body"` - Headers map[string]string `json:"headers"` -} - -// validateDomain checks if the domain is allowed by the extension's permissions -func (r *ExtensionRuntime) validateDomain(urlStr string) error { - parsed, err := url.Parse(urlStr) - if err != nil { - return fmt.Errorf("invalid URL: %w", err) - } - - domain := parsed.Hostname() - if !r.manifest.IsDomainAllowed(domain) { - return fmt.Errorf("network access denied: domain '%s' not in allowed list", domain) - } - - return nil -} - -// httpGet performs a GET request (sandboxed) -func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value { - if len(call.Arguments) < 1 { - return r.vm.ToValue(map[string]interface{}{ - "error": "URL is required", - }) - } - - urlStr := call.Arguments[0].String() - - // Validate domain - if err := r.validateDomain(urlStr); err != nil { - GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err) - return r.vm.ToValue(map[string]interface{}{ - "error": err.Error(), - }) - } - - // Get headers if provided - headers := make(map[string]string) - if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) { - headersObj := call.Arguments[1].Export() - if h, ok := headersObj.(map[string]interface{}); ok { - for k, v := range h { - headers[k] = fmt.Sprintf("%v", v) - } - } - } - - // Create request - req, err := http.NewRequest("GET", urlStr, nil) - if err != nil { - return r.vm.ToValue(map[string]interface{}{ - "error": err.Error(), - }) - } - - // Set headers - user headers first - for k, v := range headers { - req.Header.Set(k, v) - } - // Only set default User-Agent if not provided by extension - if req.Header.Get("User-Agent") == "" { - req.Header.Set("User-Agent", "Spotiflac-Extension/1.0") - } - - // Execute request - resp, err := r.httpClient.Do(req) - if err != nil { - return r.vm.ToValue(map[string]interface{}{ - "error": err.Error(), - }) - } - defer resp.Body.Close() - - // Read body - body, err := io.ReadAll(resp.Body) - if err != nil { - return r.vm.ToValue(map[string]interface{}{ - "error": err.Error(), - }) - } - - // Extract response headers - return all values as arrays for multi-value headers (cookies, etc.) - respHeaders := make(map[string]interface{}) - for k, v := range resp.Header { - if len(v) == 1 { - respHeaders[k] = v[0] - } else { - respHeaders[k] = v // Return as array if multiple values - } - } - - return r.vm.ToValue(map[string]interface{}{ - "statusCode": resp.StatusCode, - "status": resp.StatusCode, // Alias for convenience - "ok": resp.StatusCode >= 200 && resp.StatusCode < 300, - "body": string(body), - "headers": respHeaders, - }) -} - -// httpPost performs a POST request (sandboxed) -func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value { - if len(call.Arguments) < 1 { - return r.vm.ToValue(map[string]interface{}{ - "error": "URL is required", - }) - } - - urlStr := call.Arguments[0].String() - - // Validate domain - if err := r.validateDomain(urlStr); err != nil { - GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err) - return r.vm.ToValue(map[string]interface{}{ - "error": err.Error(), - }) - } - - // Get body if provided - support both string and object - var bodyStr string - if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) { - bodyArg := call.Arguments[1].Export() - switch v := bodyArg.(type) { - case string: - bodyStr = v - case map[string]interface{}, []interface{}: - // Auto-stringify objects and arrays to JSON - jsonBytes, err := json.Marshal(v) - if err != nil { - return r.vm.ToValue(map[string]interface{}{ - "error": fmt.Sprintf("failed to stringify body: %v", err), - }) - } - bodyStr = string(jsonBytes) - default: - // Fallback to string conversion - bodyStr = call.Arguments[1].String() - } - } - - // Get headers if provided - headers := make(map[string]string) - if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) { - headersObj := call.Arguments[2].Export() - if h, ok := headersObj.(map[string]interface{}); ok { - for k, v := range h { - headers[k] = fmt.Sprintf("%v", v) - } - } - } - - // Create request - req, err := http.NewRequest("POST", urlStr, strings.NewReader(bodyStr)) - if err != nil { - return r.vm.ToValue(map[string]interface{}{ - "error": err.Error(), - }) - } - - // Set headers - user headers first - for k, v := range headers { - req.Header.Set(k, v) - } - // Only set defaults if not provided by extension - if req.Header.Get("User-Agent") == "" { - req.Header.Set("User-Agent", "Spotiflac-Extension/1.0") - } - if req.Header.Get("Content-Type") == "" { - req.Header.Set("Content-Type", "application/json") - } - - // Execute request - resp, err := r.httpClient.Do(req) - if err != nil { - return r.vm.ToValue(map[string]interface{}{ - "error": err.Error(), - }) - } - defer resp.Body.Close() - - // Read body - body, err := io.ReadAll(resp.Body) - if err != nil { - return r.vm.ToValue(map[string]interface{}{ - "error": err.Error(), - }) - } - - // Extract response headers - return all values as arrays for multi-value headers - respHeaders := make(map[string]interface{}) - for k, v := range resp.Header { - if len(v) == 1 { - respHeaders[k] = v[0] - } else { - respHeaders[k] = v // Return as array if multiple values - } - } - - return r.vm.ToValue(map[string]interface{}{ - "statusCode": resp.StatusCode, - "status": resp.StatusCode, // Alias for convenience - "ok": resp.StatusCode >= 200 && resp.StatusCode < 300, - "body": string(body), - "headers": respHeaders, - }) -} - -// httpRequest performs a generic HTTP request (GET, POST, PUT, DELETE, etc.) -// Usage: http.request(url, options) where options = { method, body, headers } -func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value { - if len(call.Arguments) < 1 { - return r.vm.ToValue(map[string]interface{}{ - "error": "URL is required", - }) - } - - urlStr := call.Arguments[0].String() - - // Validate domain - if err := r.validateDomain(urlStr); err != nil { - GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err) - return r.vm.ToValue(map[string]interface{}{ - "error": err.Error(), - }) - } - - // Default options - method := "GET" - var bodyStr string - headers := make(map[string]string) - - // Parse options if provided - if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) { - optionsObj := call.Arguments[1].Export() - if opts, ok := optionsObj.(map[string]interface{}); ok { - // Get method - if m, ok := opts["method"].(string); ok { - method = strings.ToUpper(m) - } - - // Get body - support both string and object - if bodyArg, ok := opts["body"]; ok && bodyArg != nil { - switch v := bodyArg.(type) { - case string: - bodyStr = v - case map[string]interface{}, []interface{}: - // Auto-stringify objects and arrays to JSON - jsonBytes, err := json.Marshal(v) - if err != nil { - return r.vm.ToValue(map[string]interface{}{ - "error": fmt.Sprintf("failed to stringify body: %v", err), - }) - } - bodyStr = string(jsonBytes) - default: - bodyStr = fmt.Sprintf("%v", v) - } - } - - // Get headers - if h, ok := opts["headers"].(map[string]interface{}); ok { - for k, v := range h { - headers[k] = fmt.Sprintf("%v", v) - } - } - } - } - - // Create request - var reqBody io.Reader - if bodyStr != "" { - reqBody = strings.NewReader(bodyStr) - } - - req, err := http.NewRequest(method, urlStr, reqBody) - if err != nil { - return r.vm.ToValue(map[string]interface{}{ - "error": err.Error(), - }) - } - - // Set headers - user headers first - for k, v := range headers { - req.Header.Set(k, v) - } - // Only set defaults if not provided by extension - if req.Header.Get("User-Agent") == "" { - req.Header.Set("User-Agent", "Spotiflac-Extension/1.0") - } - if bodyStr != "" && req.Header.Get("Content-Type") == "" { - req.Header.Set("Content-Type", "application/json") - } - - // Execute request - resp, err := r.httpClient.Do(req) - if err != nil { - return r.vm.ToValue(map[string]interface{}{ - "error": err.Error(), - }) - } - defer resp.Body.Close() - - // Read body - body, err := io.ReadAll(resp.Body) - if err != nil { - return r.vm.ToValue(map[string]interface{}{ - "error": err.Error(), - }) - } - - // Extract response headers - return all values as arrays for multi-value headers - respHeaders := make(map[string]interface{}) - for k, v := range resp.Header { - if len(v) == 1 { - respHeaders[k] = v[0] - } else { - respHeaders[k] = v // Return as array if multiple values - } - } - - // Return response with helper properties - return r.vm.ToValue(map[string]interface{}{ - "statusCode": resp.StatusCode, - "status": resp.StatusCode, // Alias for convenience - "ok": resp.StatusCode >= 200 && resp.StatusCode < 300, - "body": string(body), - "headers": respHeaders, - }) -} - -// httpPut performs a PUT request (shortcut for http.request with method: "PUT") -func (r *ExtensionRuntime) httpPut(call goja.FunctionCall) goja.Value { - return r.httpMethodShortcut("PUT", call) -} - -// httpDelete performs a DELETE request (shortcut for http.request with method: "DELETE") -func (r *ExtensionRuntime) httpDelete(call goja.FunctionCall) goja.Value { - return r.httpMethodShortcut("DELETE", call) -} - -// httpPatch performs a PATCH request (shortcut for http.request with method: "PATCH") -func (r *ExtensionRuntime) httpPatch(call goja.FunctionCall) goja.Value { - return r.httpMethodShortcut("PATCH", call) -} - -// httpMethodShortcut is a helper for PUT/DELETE/PATCH shortcuts -// Signature: http.put(url, body, headers) / http.delete(url, headers) / http.patch(url, body, headers) -func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionCall) goja.Value { - if len(call.Arguments) < 1 { - return r.vm.ToValue(map[string]interface{}{ - "error": "URL is required", - }) - } - - urlStr := call.Arguments[0].String() - - // Validate domain - if err := r.validateDomain(urlStr); err != nil { - GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err) - return r.vm.ToValue(map[string]interface{}{ - "error": err.Error(), - }) - } - - var bodyStr string - headers := make(map[string]string) - - // For DELETE, second arg is headers; for PUT/PATCH, second arg is body - if method == "DELETE" { - // http.delete(url, headers) - if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) { - headersObj := call.Arguments[1].Export() - if h, ok := headersObj.(map[string]interface{}); ok { - for k, v := range h { - headers[k] = fmt.Sprintf("%v", v) - } - } - } - } else { - // http.put(url, body, headers) / http.patch(url, body, headers) - if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) { - bodyArg := call.Arguments[1].Export() - switch v := bodyArg.(type) { - case string: - bodyStr = v - case map[string]interface{}, []interface{}: - jsonBytes, err := json.Marshal(v) - if err != nil { - return r.vm.ToValue(map[string]interface{}{ - "error": fmt.Sprintf("failed to stringify body: %v", err), - }) - } - bodyStr = string(jsonBytes) - default: - bodyStr = call.Arguments[1].String() - } - } - - if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) { - headersObj := call.Arguments[2].Export() - if h, ok := headersObj.(map[string]interface{}); ok { - for k, v := range h { - headers[k] = fmt.Sprintf("%v", v) - } - } - } - } - - // Create request - var reqBody io.Reader - if bodyStr != "" { - reqBody = strings.NewReader(bodyStr) - } - - req, err := http.NewRequest(method, urlStr, reqBody) - if err != nil { - return r.vm.ToValue(map[string]interface{}{ - "error": err.Error(), - }) - } - - // Set headers - user headers first - for k, v := range headers { - req.Header.Set(k, v) - } - if req.Header.Get("User-Agent") == "" { - req.Header.Set("User-Agent", "Spotiflac-Extension/1.0") - } - if bodyStr != "" && req.Header.Get("Content-Type") == "" { - req.Header.Set("Content-Type", "application/json") - } - - // Execute request - resp, err := r.httpClient.Do(req) - if err != nil { - return r.vm.ToValue(map[string]interface{}{ - "error": err.Error(), - }) - } - defer resp.Body.Close() - - // Read body - body, err := io.ReadAll(resp.Body) - if err != nil { - return r.vm.ToValue(map[string]interface{}{ - "error": err.Error(), - }) - } - - // Extract response headers - respHeaders := make(map[string]interface{}) - for k, v := range resp.Header { - if len(v) == 1 { - respHeaders[k] = v[0] - } else { - respHeaders[k] = v - } - } - - return r.vm.ToValue(map[string]interface{}{ - "statusCode": resp.StatusCode, - "status": resp.StatusCode, - "ok": resp.StatusCode >= 200 && resp.StatusCode < 300, - "body": string(body), - "headers": respHeaders, - }) -} - -// httpClearCookies clears all cookies for this extension -func (r *ExtensionRuntime) httpClearCookies(call goja.FunctionCall) goja.Value { - if jar, ok := r.cookieJar.(*simpleCookieJar); ok { - jar.mu.Lock() - jar.cookies = make(map[string][]*http.Cookie) - jar.mu.Unlock() - GoLog("[Extension:%s] Cookies cleared\n", r.extensionID) - return r.vm.ToValue(true) - } - return r.vm.ToValue(false) -} - -// ==================== 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 -func (r *ExtensionRuntime) validatePath(path string) (string, error) { - // 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 - if filepath.IsAbs(cleanPath) { - return cleanPath, nil - } - - // For relative paths, join with data directory - fullPath := filepath.Join(r.dataDir, cleanPath) - - // Resolve to absolute path - absPath, err := filepath.Abs(fullPath) - if err != nil { - return "", fmt.Errorf("invalid path: %w", err) - } - - // Ensure path is within data directory - absDataDir, _ := filepath.Abs(r.dataDir) - if !strings.HasPrefix(absPath, absDataDir) { - return "", fmt.Errorf("file access denied: path '%s' is outside sandbox", path) - } - - return absPath, nil -} - -// fileDownload downloads a file from URL to the specified path -// Supports progress callback via options.onProgress -func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value { - if len(call.Arguments) < 2 { - return r.vm.ToValue(map[string]interface{}{ - "success": false, - "error": "URL and output path are required", - }) - } - - urlStr := call.Arguments[0].String() - outputPath := call.Arguments[1].String() - - // Validate domain - if err := r.validateDomain(urlStr); err != nil { - return r.vm.ToValue(map[string]interface{}{ - "success": false, - "error": err.Error(), - }) - } - - // Validate output path (allows absolute paths for download queue) - fullPath, err := r.validatePath(outputPath) - if err != nil { - return r.vm.ToValue(map[string]interface{}{ - "success": false, - "error": err.Error(), - }) - } - - // Get options if provided - var onProgress goja.Callable - var headers map[string]string - if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) { - optionsObj := call.Arguments[2].Export() - if opts, ok := optionsObj.(map[string]interface{}); ok { - // Extract headers - if h, ok := opts["headers"].(map[string]interface{}); ok { - headers = make(map[string]string) - for k, v := range h { - headers[k] = fmt.Sprintf("%v", v) - } - } - // Extract onProgress callback - if progressVal, ok := opts["onProgress"]; ok { - if callable, ok := goja.AssertFunction(r.vm.ToValue(progressVal)); ok { - onProgress = callable - } - } - } - } - - // Create directory if needed - dir := filepath.Dir(fullPath) - if err := os.MkdirAll(dir, 0755); err != nil { - return r.vm.ToValue(map[string]interface{}{ - "success": false, - "error": fmt.Sprintf("failed to create directory: %v", err), - }) - } - - // Create HTTP request - req, err := http.NewRequest("GET", urlStr, nil) - if err != nil { - return r.vm.ToValue(map[string]interface{}{ - "success": false, - "error": err.Error(), - }) - } - - // Set headers - for k, v := range headers { - req.Header.Set(k, v) - } - if req.Header.Get("User-Agent") == "" { - req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0") - } - - // Download file - resp, err := r.httpClient.Do(req) - if err != nil { - return r.vm.ToValue(map[string]interface{}{ - "success": false, - "error": err.Error(), - }) - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return r.vm.ToValue(map[string]interface{}{ - "success": false, - "error": fmt.Sprintf("HTTP error: %d", resp.StatusCode), - }) - } - - // Create output file - out, err := os.Create(fullPath) - if err != nil { - return r.vm.ToValue(map[string]interface{}{ - "success": false, - "error": fmt.Sprintf("failed to create file: %v", err), - }) - } - defer out.Close() - - // Get content length for progress - contentLength := resp.ContentLength - - // Copy content with progress reporting - var written int64 - buf := make([]byte, 32*1024) // 32KB buffer - for { - nr, er := resp.Body.Read(buf) - if nr > 0 { - nw, ew := out.Write(buf[0:nr]) - if nw < 0 || nr < nw { - nw = 0 - if ew == nil { - ew = fmt.Errorf("invalid write result") - } - } - written += int64(nw) - if ew != nil { - return r.vm.ToValue(map[string]interface{}{ - "success": false, - "error": fmt.Sprintf("failed to write file: %v", ew), - }) - } - if nr != nw { - return r.vm.ToValue(map[string]interface{}{ - "success": false, - "error": "short write", - }) - } - - // Report progress - if onProgress != nil && contentLength > 0 { - _, _ = onProgress(goja.Undefined(), r.vm.ToValue(written), r.vm.ToValue(contentLength)) - } - } - if er != nil { - if er != io.EOF { - return r.vm.ToValue(map[string]interface{}{ - "success": false, - "error": fmt.Sprintf("failed to read response: %v", er), - }) - } - break - } - } - - GoLog("[Extension:%s] Downloaded %d bytes to %s\n", r.extensionID, written, fullPath) - - return r.vm.ToValue(map[string]interface{}{ - "success": true, - "path": fullPath, - "size": written, - }) -} - -// fileExists checks if a file exists in the sandbox -func (r *ExtensionRuntime) fileExists(call goja.FunctionCall) goja.Value { - if len(call.Arguments) < 1 { - return r.vm.ToValue(false) - } - - path := call.Arguments[0].String() - fullPath, err := r.validatePath(path) - if err != nil { - return r.vm.ToValue(false) - } - - _, err = os.Stat(fullPath) - return r.vm.ToValue(err == nil) -} - -// fileDelete deletes a file in the sandbox -func (r *ExtensionRuntime) fileDelete(call goja.FunctionCall) goja.Value { - if len(call.Arguments) < 1 { - return r.vm.ToValue(map[string]interface{}{ - "success": false, - "error": "path is required", - }) - } - - path := call.Arguments[0].String() - fullPath, err := r.validatePath(path) - if err != nil { - return r.vm.ToValue(map[string]interface{}{ - "success": false, - "error": err.Error(), - }) - } - - if err := os.Remove(fullPath); err != nil { - return r.vm.ToValue(map[string]interface{}{ - "success": false, - "error": err.Error(), - }) - } - - return r.vm.ToValue(map[string]interface{}{ - "success": true, - }) -} - -// fileRead reads a file from the sandbox -func (r *ExtensionRuntime) fileRead(call goja.FunctionCall) goja.Value { - if len(call.Arguments) < 1 { - return r.vm.ToValue(map[string]interface{}{ - "success": false, - "error": "path is required", - }) - } - - path := call.Arguments[0].String() - fullPath, err := r.validatePath(path) - if err != nil { - return r.vm.ToValue(map[string]interface{}{ - "success": false, - "error": err.Error(), - }) - } - - data, err := os.ReadFile(fullPath) - if err != nil { - return r.vm.ToValue(map[string]interface{}{ - "success": false, - "error": err.Error(), - }) - } - - return r.vm.ToValue(map[string]interface{}{ - "success": true, - "data": string(data), - }) -} - -// fileWrite writes data to a file in the sandbox -func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value { - if len(call.Arguments) < 2 { - return r.vm.ToValue(map[string]interface{}{ - "success": false, - "error": "path and data are required", - }) - } - - path := call.Arguments[0].String() - data := call.Arguments[1].String() - - fullPath, err := r.validatePath(path) - if err != nil { - return r.vm.ToValue(map[string]interface{}{ - "success": false, - "error": err.Error(), - }) - } - - // Create directory if needed - dir := filepath.Dir(fullPath) - if err := os.MkdirAll(dir, 0755); err != nil { - return r.vm.ToValue(map[string]interface{}{ - "success": false, - "error": fmt.Sprintf("failed to create directory: %v", err), - }) - } - - if err := os.WriteFile(fullPath, []byte(data), 0644); err != nil { - return r.vm.ToValue(map[string]interface{}{ - "success": false, - "error": err.Error(), - }) - } - - return r.vm.ToValue(map[string]interface{}{ - "success": true, - "path": fullPath, - }) -} - -// ==================== Storage API ==================== - -// getStoragePath returns the path to the extension's storage file -func (r *ExtensionRuntime) getStoragePath() string { - return filepath.Join(r.dataDir, "storage.json") -} - -// loadStorage loads the storage data from disk -func (r *ExtensionRuntime) loadStorage() (map[string]interface{}, error) { - storagePath := r.getStoragePath() - data, err := os.ReadFile(storagePath) - if err != nil { - if os.IsNotExist(err) { - return make(map[string]interface{}), nil - } - return nil, err - } - - var storage map[string]interface{} - if err := json.Unmarshal(data, &storage); err != nil { - return nil, err - } - - return storage, nil -} - -// saveStorage saves the storage data to disk -func (r *ExtensionRuntime) saveStorage(storage map[string]interface{}) error { - storagePath := r.getStoragePath() - data, err := json.MarshalIndent(storage, "", " ") - if err != nil { - return err - } - - return os.WriteFile(storagePath, data, 0644) -} - -// storageGet retrieves a value from storage -func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value { - if len(call.Arguments) < 1 { - return goja.Undefined() - } - - key := call.Arguments[0].String() - - storage, err := r.loadStorage() - if err != nil { - GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err) - return goja.Undefined() - } - - value, exists := storage[key] - if !exists { - // Return default value if provided - if len(call.Arguments) > 1 { - return call.Arguments[1] - } - return goja.Undefined() - } - - return r.vm.ToValue(value) -} - -// storageSet stores a value in storage -func (r *ExtensionRuntime) storageSet(call goja.FunctionCall) goja.Value { - if len(call.Arguments) < 2 { - return r.vm.ToValue(false) - } - - key := call.Arguments[0].String() - value := call.Arguments[1].Export() - - storage, err := r.loadStorage() - if err != nil { - GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err) - return r.vm.ToValue(false) - } - - storage[key] = value - - if err := r.saveStorage(storage); err != nil { - GoLog("[Extension:%s] Storage save error: %v\n", r.extensionID, err) - return r.vm.ToValue(false) - } - - return r.vm.ToValue(true) -} - -// storageRemove removes a value from storage -func (r *ExtensionRuntime) storageRemove(call goja.FunctionCall) goja.Value { - if len(call.Arguments) < 1 { - return r.vm.ToValue(false) - } - - key := call.Arguments[0].String() - - storage, err := r.loadStorage() - if err != nil { - GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err) - return r.vm.ToValue(false) - } - - delete(storage, key) - - if err := r.saveStorage(storage); err != nil { - GoLog("[Extension:%s] Storage save error: %v\n", r.extensionID, err) - return r.vm.ToValue(false) - } - - return r.vm.ToValue(true) -} - -// ==================== Utility Functions ==================== - -// base64Encode encodes a string to base64 -func (r *ExtensionRuntime) base64Encode(call goja.FunctionCall) goja.Value { - if len(call.Arguments) < 1 { - return r.vm.ToValue("") - } - input := call.Arguments[0].String() - return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input))) -} - -// base64Decode decodes a base64 string -func (r *ExtensionRuntime) base64Decode(call goja.FunctionCall) goja.Value { - if len(call.Arguments) < 1 { - return r.vm.ToValue("") - } - input := call.Arguments[0].String() - decoded, err := base64.StdEncoding.DecodeString(input) - if err != nil { - return r.vm.ToValue("") - } - return r.vm.ToValue(string(decoded)) -} - -// md5Hash computes MD5 hash of a string -func (r *ExtensionRuntime) md5Hash(call goja.FunctionCall) goja.Value { - if len(call.Arguments) < 1 { - return r.vm.ToValue("") - } - input := call.Arguments[0].String() - hash := md5.Sum([]byte(input)) - return r.vm.ToValue(hex.EncodeToString(hash[:])) -} - -// sha256Hash computes SHA256 hash of a string -func (r *ExtensionRuntime) sha256Hash(call goja.FunctionCall) goja.Value { - if len(call.Arguments) < 1 { - return r.vm.ToValue("") - } - input := call.Arguments[0].String() - hash := sha256.Sum256([]byte(input)) - return r.vm.ToValue(hex.EncodeToString(hash[:])) -} - -// parseJSON parses a JSON string -func (r *ExtensionRuntime) parseJSON(call goja.FunctionCall) goja.Value { - if len(call.Arguments) < 1 { - return goja.Undefined() - } - input := call.Arguments[0].String() - - var result interface{} - if err := json.Unmarshal([]byte(input), &result); err != nil { - GoLog("[Extension:%s] JSON parse error: %v\n", r.extensionID, err) - return goja.Undefined() - } - - return r.vm.ToValue(result) -} - -// stringifyJSON converts a value to JSON string -func (r *ExtensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value { - if len(call.Arguments) < 1 { - return r.vm.ToValue("") - } - input := call.Arguments[0].Export() - - data, err := json.Marshal(input) - if err != nil { - GoLog("[Extension:%s] JSON stringify error: %v\n", r.extensionID, err) - return r.vm.ToValue("") - } - - return r.vm.ToValue(string(data)) -} - -// ==================== Logging Functions ==================== - -func (r *ExtensionRuntime) logDebug(call goja.FunctionCall) goja.Value { - msg := r.formatLogArgs(call.Arguments) - GoLog("[Extension:%s:DEBUG] %s\n", r.extensionID, msg) - return goja.Undefined() -} - -func (r *ExtensionRuntime) logInfo(call goja.FunctionCall) goja.Value { - msg := r.formatLogArgs(call.Arguments) - GoLog("[Extension:%s:INFO] %s\n", r.extensionID, msg) - return goja.Undefined() -} - -func (r *ExtensionRuntime) logWarn(call goja.FunctionCall) goja.Value { - msg := r.formatLogArgs(call.Arguments) - GoLog("[Extension:%s:WARN] %s\n", r.extensionID, msg) - return goja.Undefined() -} - -func (r *ExtensionRuntime) logError(call goja.FunctionCall) goja.Value { - msg := r.formatLogArgs(call.Arguments) - GoLog("[Extension:%s:ERROR] %s\n", r.extensionID, msg) - return goja.Undefined() -} - -func (r *ExtensionRuntime) formatLogArgs(args []goja.Value) string { - parts := make([]string, len(args)) - for i, arg := range args { - parts[i] = fmt.Sprintf("%v", arg.Export()) - } - return strings.Join(parts, " ") -} - -// ==================== Go Backend Wrappers ==================== - -func (r *ExtensionRuntime) sanitizeFilenameWrapper(call goja.FunctionCall) goja.Value { - if len(call.Arguments) < 1 { - return r.vm.ToValue("") - } - input := call.Arguments[0].String() - return r.vm.ToValue(sanitizeFilename(input)) -} - -// RegisterGoBackendAPIs adds more Go backend functions to the VM -func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) { - gobackendObj := vm.Get("gobackend") - if gobackendObj == nil || goja.IsUndefined(gobackendObj) { - gobackendObj = vm.NewObject() - vm.Set("gobackend", gobackendObj) - } - - obj := gobackendObj.(*goja.Object) - - // Expose sanitizeFilename - obj.Set("sanitizeFilename", func(call goja.FunctionCall) goja.Value { - if len(call.Arguments) < 1 { - return vm.ToValue("") - } - return vm.ToValue(sanitizeFilename(call.Arguments[0].String())) - }) - - // Expose getAudioQuality - obj.Set("getAudioQuality", func(call goja.FunctionCall) goja.Value { - if len(call.Arguments) < 1 { - return vm.ToValue(map[string]interface{}{ - "error": "file path is required", - }) - } - - filePath := call.Arguments[0].String() - quality, err := GetAudioQuality(filePath) - if err != nil { - return vm.ToValue(map[string]interface{}{ - "error": err.Error(), - }) - } - - return vm.ToValue(map[string]interface{}{ - "bitDepth": quality.BitDepth, - "sampleRate": quality.SampleRate, - "totalSamples": quality.TotalSamples, - }) - }) - - // Expose buildFilename - obj.Set("buildFilename", func(call goja.FunctionCall) goja.Value { - if len(call.Arguments) < 2 { - return vm.ToValue("") - } - - template := call.Arguments[0].String() - metadataObj := call.Arguments[1].Export() - - metadata, ok := metadataObj.(map[string]interface{}) - if !ok { - return vm.ToValue("") - } - - return vm.ToValue(buildFilenameFromTemplate(template, metadata)) - }) -} - -// ==================== Credentials API (Encrypted Storage) ==================== - -// getCredentialsPath returns the path to the extension's encrypted credentials file -func (r *ExtensionRuntime) getCredentialsPath() string { - return filepath.Join(r.dataDir, ".credentials.enc") -} - -// getEncryptionKey derives an encryption key from extension ID -func (r *ExtensionRuntime) getEncryptionKey() []byte { - // Use SHA256 of extension ID + salt as encryption key - salt := "spotiflac-ext-cred-v1" - hash := sha256.Sum256([]byte(r.extensionID + salt)) - return hash[:] -} - -// loadCredentials loads and decrypts credentials from disk -func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) { - credPath := r.getCredentialsPath() - data, err := os.ReadFile(credPath) - if err != nil { - if os.IsNotExist(err) { - return make(map[string]interface{}), nil - } - return nil, err - } - - // Decrypt the data - key := r.getEncryptionKey() - decrypted, err := decryptAES(data, key) - if err != nil { - return nil, fmt.Errorf("failed to decrypt credentials: %w", err) - } - - var creds map[string]interface{} - if err := json.Unmarshal(decrypted, &creds); err != nil { - return nil, err - } - - return creds, nil -} - -// saveCredentials encrypts and saves credentials to disk -func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error { - data, err := json.Marshal(creds) - if err != nil { - return err - } - - // Encrypt the data - key := r.getEncryptionKey() - encrypted, err := encryptAES(data, key) - if err != nil { - return fmt.Errorf("failed to encrypt credentials: %w", err) - } - - credPath := r.getCredentialsPath() - return os.WriteFile(credPath, encrypted, 0600) // Restrictive permissions -} - -// credentialsStore stores an encrypted credential -func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value { - if len(call.Arguments) < 2 { - return r.vm.ToValue(map[string]interface{}{ - "success": false, - "error": "key and value are required", - }) - } - - key := call.Arguments[0].String() - value := call.Arguments[1].Export() - - creds, err := r.loadCredentials() - if err != nil { - GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err) - return r.vm.ToValue(map[string]interface{}{ - "success": false, - "error": err.Error(), - }) - } - - creds[key] = value - - if err := r.saveCredentials(creds); err != nil { - GoLog("[Extension:%s] Credentials save error: %v\n", r.extensionID, err) - return r.vm.ToValue(map[string]interface{}{ - "success": false, - "error": err.Error(), - }) - } - - return r.vm.ToValue(map[string]interface{}{ - "success": true, - }) -} - -// credentialsGet retrieves a decrypted credential -func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value { - if len(call.Arguments) < 1 { - return goja.Undefined() - } - - key := call.Arguments[0].String() - - creds, err := r.loadCredentials() - if err != nil { - GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err) - return goja.Undefined() - } - - value, exists := creds[key] - if !exists { - // Return default value if provided - if len(call.Arguments) > 1 { - return call.Arguments[1] - } - return goja.Undefined() - } - - return r.vm.ToValue(value) -} - -// credentialsRemove removes a credential -func (r *ExtensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value { - if len(call.Arguments) < 1 { - return r.vm.ToValue(false) - } - - key := call.Arguments[0].String() - - creds, err := r.loadCredentials() - if err != nil { - GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err) - return r.vm.ToValue(false) - } - - delete(creds, key) - - if err := r.saveCredentials(creds); err != nil { - GoLog("[Extension:%s] Credentials save error: %v\n", r.extensionID, err) - return r.vm.ToValue(false) - } - - return r.vm.ToValue(true) -} - -// credentialsHas checks if a credential exists -func (r *ExtensionRuntime) credentialsHas(call goja.FunctionCall) goja.Value { - if len(call.Arguments) < 1 { - return r.vm.ToValue(false) - } - - key := call.Arguments[0].String() - - creds, err := r.loadCredentials() - if err != nil { - return r.vm.ToValue(false) - } - - _, exists := creds[key] - return r.vm.ToValue(exists) -} - -// ==================== Auth API (OAuth Support) ==================== - -// authOpenUrl requests Flutter to open an OAuth URL -func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value { - if len(call.Arguments) < 1 { - return r.vm.ToValue(map[string]interface{}{ - "success": false, - "error": "auth URL is required", - }) - } - - authURL := call.Arguments[0].String() - callbackURL := "" - if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) { - callbackURL = call.Arguments[1].String() - } - - // Store pending auth request for Flutter to pick up - pendingAuthRequestsMu.Lock() - pendingAuthRequests[r.extensionID] = &PendingAuthRequest{ - ExtensionID: r.extensionID, - AuthURL: authURL, - CallbackURL: callbackURL, - } - pendingAuthRequestsMu.Unlock() - - // Update auth state - extensionAuthStateMu.Lock() - state, exists := extensionAuthState[r.extensionID] - if !exists { - state = &ExtensionAuthState{} - extensionAuthState[r.extensionID] = state - } - state.PendingAuthURL = authURL - state.AuthCode = "" // Clear any previous auth code - extensionAuthStateMu.Unlock() - - GoLog("[Extension:%s] Auth URL requested: %s\n", r.extensionID, authURL) - - return r.vm.ToValue(map[string]interface{}{ - "success": true, - "message": "Auth URL will be opened by the app", - }) -} - -// authGetCode gets the auth code (set by Flutter after OAuth callback) -func (r *ExtensionRuntime) authGetCode(call goja.FunctionCall) goja.Value { - extensionAuthStateMu.RLock() - defer extensionAuthStateMu.RUnlock() - - state, exists := extensionAuthState[r.extensionID] - if !exists || state.AuthCode == "" { - return goja.Undefined() - } - - return r.vm.ToValue(state.AuthCode) -} - -// authSetCode sets auth code and tokens (can be called by extension after token exchange) -func (r *ExtensionRuntime) authSetCode(call goja.FunctionCall) goja.Value { - if len(call.Arguments) < 1 { - return r.vm.ToValue(false) - } - - // Can accept either just auth code or an object with tokens - arg := call.Arguments[0].Export() - - extensionAuthStateMu.Lock() - defer extensionAuthStateMu.Unlock() - - state, exists := extensionAuthState[r.extensionID] - if !exists { - state = &ExtensionAuthState{} - extensionAuthState[r.extensionID] = state - } - - switch v := arg.(type) { - case string: - state.AuthCode = v - case map[string]interface{}: - if code, ok := v["code"].(string); ok { - state.AuthCode = code - } - if accessToken, ok := v["access_token"].(string); ok { - state.AccessToken = accessToken - state.IsAuthenticated = true - } - if refreshToken, ok := v["refresh_token"].(string); ok { - state.RefreshToken = refreshToken - } - if expiresIn, ok := v["expires_in"].(float64); ok { - state.ExpiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second) - } - } - - return r.vm.ToValue(true) -} - -// authClear clears all auth state for the extension -func (r *ExtensionRuntime) authClear(call goja.FunctionCall) goja.Value { - extensionAuthStateMu.Lock() - delete(extensionAuthState, r.extensionID) - extensionAuthStateMu.Unlock() - - pendingAuthRequestsMu.Lock() - delete(pendingAuthRequests, r.extensionID) - pendingAuthRequestsMu.Unlock() - - GoLog("[Extension:%s] Auth state cleared\n", r.extensionID) - return r.vm.ToValue(true) -} - -// authIsAuthenticated checks if extension has valid auth -func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Value { - extensionAuthStateMu.RLock() - defer extensionAuthStateMu.RUnlock() - - state, exists := extensionAuthState[r.extensionID] - if !exists { - return r.vm.ToValue(false) - } - - // Check if token is expired - if state.IsAuthenticated && !state.ExpiresAt.IsZero() && time.Now().After(state.ExpiresAt) { - return r.vm.ToValue(false) - } - - return r.vm.ToValue(state.IsAuthenticated) -} - -// authGetTokens returns current tokens (for extension to use in API calls) -func (r *ExtensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value { - extensionAuthStateMu.RLock() - defer extensionAuthStateMu.RUnlock() - - state, exists := extensionAuthState[r.extensionID] - if !exists { - return r.vm.ToValue(map[string]interface{}{}) - } - - result := map[string]interface{}{ - "access_token": state.AccessToken, - "refresh_token": state.RefreshToken, - "is_authenticated": state.IsAuthenticated, - } - - if !state.ExpiresAt.IsZero() { - result["expires_at"] = state.ExpiresAt.Unix() - result["is_expired"] = time.Now().After(state.ExpiresAt) - } - - return r.vm.ToValue(result) -} - -// ==================== Crypto Utilities ==================== - -// encryptAES encrypts data using AES-GCM -func encryptAES(plaintext []byte, key []byte) ([]byte, error) { - block, err := aes.NewCipher(key) - if err != nil { - return nil, err - } - - gcm, err := cipher.NewGCM(block) - if err != nil { - return nil, err - } - - nonce := make([]byte, gcm.NonceSize()) - if _, err := io.ReadFull(rand.Reader, nonce); err != nil { - return nil, err - } - - ciphertext := gcm.Seal(nonce, nonce, plaintext, nil) - return ciphertext, nil -} - -// decryptAES decrypts data using AES-GCM -func decryptAES(ciphertext []byte, key []byte) ([]byte, error) { - block, err := aes.NewCipher(key) - if err != nil { - return nil, err - } - - gcm, err := cipher.NewGCM(block) - if err != nil { - return nil, err - } - - nonceSize := gcm.NonceSize() - if len(ciphertext) < nonceSize { - return nil, fmt.Errorf("ciphertext too short") - } - - nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:] - plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) - if err != nil { - return nil, err - } - - return plaintext, nil -} - -// cryptoEncrypt encrypts a string using AES-GCM (for extension use) -func (r *ExtensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value { - if len(call.Arguments) < 2 { - return r.vm.ToValue(map[string]interface{}{ - "success": false, - "error": "plaintext and key are required", - }) - } - - plaintext := call.Arguments[0].String() - keyStr := call.Arguments[1].String() - - // Derive 32-byte key from provided key string - keyHash := sha256.Sum256([]byte(keyStr)) - - encrypted, err := encryptAES([]byte(plaintext), keyHash[:]) - if err != nil { - return r.vm.ToValue(map[string]interface{}{ - "success": false, - "error": err.Error(), - }) - } - - return r.vm.ToValue(map[string]interface{}{ - "success": true, - "data": base64.StdEncoding.EncodeToString(encrypted), - }) -} - -// cryptoDecrypt decrypts a string using AES-GCM (for extension use) -func (r *ExtensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value { - if len(call.Arguments) < 2 { - return r.vm.ToValue(map[string]interface{}{ - "success": false, - "error": "ciphertext and key are required", - }) - } - - ciphertextB64 := call.Arguments[0].String() - keyStr := call.Arguments[1].String() - - ciphertext, err := base64.StdEncoding.DecodeString(ciphertextB64) - if err != nil { - return r.vm.ToValue(map[string]interface{}{ - "success": false, - "error": "invalid base64 ciphertext", - }) - } - - // Derive 32-byte key from provided key string - keyHash := sha256.Sum256([]byte(keyStr)) - - decrypted, err := decryptAES(ciphertext, keyHash[:]) - if err != nil { - return r.vm.ToValue(map[string]interface{}{ - "success": false, - "error": err.Error(), - }) - } - - return r.vm.ToValue(map[string]interface{}{ - "success": true, - "data": string(decrypted), - }) -} - -// cryptoGenerateKey generates a random encryption key -func (r *ExtensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value { - length := 32 // Default 256-bit key - if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) { - if l, ok := call.Arguments[0].Export().(float64); ok { - length = int(l) - } - } - - key := make([]byte, length) - if _, err := rand.Read(key); err != nil { - return r.vm.ToValue(map[string]interface{}{ - "success": false, - "error": err.Error(), - }) - } - - return r.vm.ToValue(map[string]interface{}{ - "success": true, - "key": base64.StdEncoding.EncodeToString(key), - "hex": hex.EncodeToString(key), - }) -} - -// ==================== Additional File Operations ==================== - -// fileCopy copies a file within the sandbox -func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value { - if len(call.Arguments) < 2 { - return r.vm.ToValue(map[string]interface{}{ - "success": false, - "error": "source and destination paths are required", - }) - } - - srcPath := call.Arguments[0].String() - dstPath := call.Arguments[1].String() - - fullSrc, err := r.validatePath(srcPath) - if err != nil { - return r.vm.ToValue(map[string]interface{}{ - "success": false, - "error": err.Error(), - }) - } - - fullDst, err := r.validatePath(dstPath) - if err != nil { - return r.vm.ToValue(map[string]interface{}{ - "success": false, - "error": err.Error(), - }) - } - - // Read source file - data, err := os.ReadFile(fullSrc) - if err != nil { - return r.vm.ToValue(map[string]interface{}{ - "success": false, - "error": fmt.Sprintf("failed to read source: %v", err), - }) - } - - // Create destination directory if needed - dir := filepath.Dir(fullDst) - if err := os.MkdirAll(dir, 0755); err != nil { - return r.vm.ToValue(map[string]interface{}{ - "success": false, - "error": fmt.Sprintf("failed to create directory: %v", err), - }) - } - - // Write to destination - if err := os.WriteFile(fullDst, data, 0644); err != nil { - return r.vm.ToValue(map[string]interface{}{ - "success": false, - "error": fmt.Sprintf("failed to write destination: %v", err), - }) - } - - return r.vm.ToValue(map[string]interface{}{ - "success": true, - "path": fullDst, - }) -} - -// fileMove moves/renames a file within the sandbox -func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value { - if len(call.Arguments) < 2 { - return r.vm.ToValue(map[string]interface{}{ - "success": false, - "error": "source and destination paths are required", - }) - } - - srcPath := call.Arguments[0].String() - dstPath := call.Arguments[1].String() - - fullSrc, err := r.validatePath(srcPath) - if err != nil { - return r.vm.ToValue(map[string]interface{}{ - "success": false, - "error": err.Error(), - }) - } - - fullDst, err := r.validatePath(dstPath) - if err != nil { - return r.vm.ToValue(map[string]interface{}{ - "success": false, - "error": err.Error(), - }) - } - - // Create destination directory if needed - dir := filepath.Dir(fullDst) - if err := os.MkdirAll(dir, 0755); err != nil { - return r.vm.ToValue(map[string]interface{}{ - "success": false, - "error": fmt.Sprintf("failed to create directory: %v", err), - }) - } - - if err := os.Rename(fullSrc, fullDst); err != nil { - return r.vm.ToValue(map[string]interface{}{ - "success": false, - "error": fmt.Sprintf("failed to move file: %v", err), - }) - } - - return r.vm.ToValue(map[string]interface{}{ - "success": true, - "path": fullDst, - }) -} - -// fileGetSize returns the size of a file in bytes -func (r *ExtensionRuntime) fileGetSize(call goja.FunctionCall) goja.Value { - if len(call.Arguments) < 1 { - return r.vm.ToValue(map[string]interface{}{ - "success": false, - "error": "path is required", - }) - } - - path := call.Arguments[0].String() - fullPath, err := r.validatePath(path) - if err != nil { - return r.vm.ToValue(map[string]interface{}{ - "success": false, - "error": err.Error(), - }) - } - - info, err := os.Stat(fullPath) - if err != nil { - return r.vm.ToValue(map[string]interface{}{ - "success": false, - "error": err.Error(), - }) - } - - return r.vm.ToValue(map[string]interface{}{ - "success": true, - "size": info.Size(), - }) -} - -// ==================== FFmpeg API (Post-Processing) ==================== - -// FFmpegCommand holds a pending FFmpeg command for Flutter to execute -type FFmpegCommand struct { - ExtensionID string - Command string - InputPath string - OutputPath string - Completed bool - Success bool - Error string - Output string -} - -// Global FFmpeg command queue -var ( - ffmpegCommands = make(map[string]*FFmpegCommand) - ffmpegCommandsMu sync.RWMutex - ffmpegCommandID int64 -) - -// GetPendingFFmpegCommand returns a pending FFmpeg command (called from Flutter) -func GetPendingFFmpegCommand(commandID string) *FFmpegCommand { - ffmpegCommandsMu.RLock() - defer ffmpegCommandsMu.RUnlock() - return ffmpegCommands[commandID] -} - -// SetFFmpegCommandResult sets the result of an FFmpeg command (called from Flutter) -func SetFFmpegCommandResult(commandID string, success bool, output, errorMsg string) { - ffmpegCommandsMu.Lock() - defer ffmpegCommandsMu.Unlock() - if cmd, exists := ffmpegCommands[commandID]; exists { - cmd.Completed = true - cmd.Success = success - cmd.Output = output - cmd.Error = errorMsg - } -} - -// ClearFFmpegCommand removes a completed FFmpeg command -func ClearFFmpegCommand(commandID string) { - ffmpegCommandsMu.Lock() - defer ffmpegCommandsMu.Unlock() - delete(ffmpegCommands, commandID) -} - -// ffmpegExecute queues an FFmpeg command for execution by Flutter -func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value { - if len(call.Arguments) < 1 { - return r.vm.ToValue(map[string]interface{}{ - "success": false, - "error": "command is required", - }) - } - - command := call.Arguments[0].String() - - // Generate unique command ID - ffmpegCommandsMu.Lock() - ffmpegCommandID++ - cmdID := fmt.Sprintf("%s_%d", r.extensionID, ffmpegCommandID) - ffmpegCommands[cmdID] = &FFmpegCommand{ - ExtensionID: r.extensionID, - Command: command, - Completed: false, - } - ffmpegCommandsMu.Unlock() - - GoLog("[Extension:%s] FFmpeg command queued: %s\n", r.extensionID, cmdID) - - // Wait for completion (with timeout) - timeout := 5 * time.Minute - start := time.Now() - for { - ffmpegCommandsMu.RLock() - cmd := ffmpegCommands[cmdID] - completed := cmd != nil && cmd.Completed - ffmpegCommandsMu.RUnlock() - - if completed { - ffmpegCommandsMu.RLock() - result := map[string]interface{}{ - "success": cmd.Success, - "output": cmd.Output, - } - if cmd.Error != "" { - result["error"] = cmd.Error - } - ffmpegCommandsMu.RUnlock() - - // Cleanup - ClearFFmpegCommand(cmdID) - return r.vm.ToValue(result) - } - - if time.Since(start) > timeout { - ClearFFmpegCommand(cmdID) - return r.vm.ToValue(map[string]interface{}{ - "success": false, - "error": "FFmpeg command timed out", - }) - } - - time.Sleep(100 * time.Millisecond) - } -} - -// ffmpegGetInfo gets audio file information using FFprobe -func (r *ExtensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value { - if len(call.Arguments) < 1 { - return r.vm.ToValue(map[string]interface{}{ - "success": false, - "error": "file path is required", - }) - } - - filePath := call.Arguments[0].String() - - // Use Go's built-in audio quality function - quality, err := GetAudioQuality(filePath) - if err != nil { - return r.vm.ToValue(map[string]interface{}{ - "success": false, - "error": err.Error(), - }) - } - - return r.vm.ToValue(map[string]interface{}{ - "success": true, - "bit_depth": quality.BitDepth, - "sample_rate": quality.SampleRate, - "total_samples": quality.TotalSamples, - "duration": float64(quality.TotalSamples) / float64(quality.SampleRate), - }) -} - -// ffmpegConvert is a helper for common conversion operations -func (r *ExtensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value { - if len(call.Arguments) < 2 { - return r.vm.ToValue(map[string]interface{}{ - "success": false, - "error": "input and output paths are required", - }) - } - - inputPath := call.Arguments[0].String() - outputPath := call.Arguments[1].String() - - // Get options if provided - options := map[string]interface{}{} - if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) { - if opts, ok := call.Arguments[2].Export().(map[string]interface{}); ok { - options = opts - } - } - - // Build FFmpeg command - var cmdParts []string - cmdParts = append(cmdParts, "-i", fmt.Sprintf("%q", inputPath)) - - // Audio codec - if codec, ok := options["codec"].(string); ok { - cmdParts = append(cmdParts, "-c:a", codec) - } - - // Bitrate - if bitrate, ok := options["bitrate"].(string); ok { - cmdParts = append(cmdParts, "-b:a", bitrate) - } - - // Sample rate - if sampleRate, ok := options["sample_rate"].(float64); ok { - cmdParts = append(cmdParts, "-ar", fmt.Sprintf("%d", int(sampleRate))) - } - - // Channels - if channels, ok := options["channels"].(float64); ok { - cmdParts = append(cmdParts, "-ac", fmt.Sprintf("%d", int(channels))) - } - - // Overwrite output - cmdParts = append(cmdParts, "-y", fmt.Sprintf("%q", outputPath)) - - command := strings.Join(cmdParts, " ") - - // Execute via ffmpegExecute - execCall := goja.FunctionCall{ - Arguments: []goja.Value{r.vm.ToValue(command)}, - } - return r.ffmpegExecute(execCall) -} - -// ==================== Track Matching API ==================== - -// matchingCompareStrings compares two strings with fuzzy matching -func (r *ExtensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.Value { - if len(call.Arguments) < 2 { - return r.vm.ToValue(0.0) - } - - str1 := strings.ToLower(strings.TrimSpace(call.Arguments[0].String())) - str2 := strings.ToLower(strings.TrimSpace(call.Arguments[1].String())) - - if str1 == str2 { - return r.vm.ToValue(1.0) - } - - // Calculate Levenshtein distance-based similarity - similarity := calculateStringSimilarity(str1, str2) - return r.vm.ToValue(similarity) -} - -// matchingCompareDuration compares two durations with tolerance -func (r *ExtensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.Value { - if len(call.Arguments) < 2 { - return r.vm.ToValue(false) - } - - dur1 := int(call.Arguments[0].ToInteger()) - dur2 := int(call.Arguments[1].ToInteger()) - - // Default tolerance: 3 seconds - tolerance := 3000 // milliseconds - if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) { - tolerance = int(call.Arguments[2].ToInteger()) - } - - diff := dur1 - dur2 - if diff < 0 { - diff = -diff - } - - return r.vm.ToValue(diff <= tolerance) -} - -// matchingNormalizeString normalizes a string for comparison -func (r *ExtensionRuntime) matchingNormalizeString(call goja.FunctionCall) goja.Value { - if len(call.Arguments) < 1 { - return r.vm.ToValue("") - } - - str := call.Arguments[0].String() - normalized := normalizeStringForMatching(str) - return r.vm.ToValue(normalized) -} - -// calculateStringSimilarity calculates similarity between two strings (0-1) -func calculateStringSimilarity(s1, s2 string) float64 { - if len(s1) == 0 && len(s2) == 0 { - return 1.0 - } - if len(s1) == 0 || len(s2) == 0 { - return 0.0 - } - - // Use Levenshtein distance - distance := levenshteinDistance(s1, s2) - maxLen := len(s1) - if len(s2) > maxLen { - maxLen = len(s2) - } - - return 1.0 - float64(distance)/float64(maxLen) -} - -// levenshteinDistance calculates the Levenshtein distance between two strings -func levenshteinDistance(s1, s2 string) int { - if len(s1) == 0 { - return len(s2) - } - if len(s2) == 0 { - return len(s1) - } - - // Create matrix - matrix := make([][]int, len(s1)+1) - for i := range matrix { - matrix[i] = make([]int, len(s2)+1) - matrix[i][0] = i - } - for j := range matrix[0] { - matrix[0][j] = j - } - - // Fill matrix - for i := 1; i <= len(s1); i++ { - for j := 1; j <= len(s2); j++ { - cost := 1 - if s1[i-1] == s2[j-1] { - cost = 0 - } - matrix[i][j] = min( - matrix[i-1][j]+1, // deletion - matrix[i][j-1]+1, // insertion - matrix[i-1][j-1]+cost, // substitution - ) - } - } - - return matrix[len(s1)][len(s2)] -} - -// normalizeStringForMatching normalizes a string for comparison -func normalizeStringForMatching(s string) string { - // Convert to lowercase - s = strings.ToLower(s) - - // Remove common suffixes/prefixes - suffixes := []string{ - " (remastered)", " (remaster)", " - remastered", " - remaster", - " (deluxe)", " (deluxe edition)", " - deluxe", " - deluxe edition", - " (explicit)", " (clean)", " [explicit]", " [clean]", - " (album version)", " (single version)", " (radio edit)", - " (feat.", " (ft.", " feat.", " ft.", - } - for _, suffix := range suffixes { - if idx := strings.Index(s, suffix); idx != -1 { - s = s[:idx] - } - } - - // Remove special characters - var result strings.Builder - for _, r := range s { - if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == ' ' { - result.WriteRune(r) - } - } - - // Collapse multiple spaces - s = strings.Join(strings.Fields(result.String()), " ") - - return strings.TrimSpace(s) -} - -// ==================== Browser-like Polyfills ==================== -// These polyfills make porting browser/Node.js libraries easier -// without compromising sandbox security - -// fetchPolyfill implements browser-compatible fetch() API -// Returns a Promise-like object with json(), text() methods -func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value { - if len(call.Arguments) < 1 { - return r.createFetchError("URL is required") - } - - urlStr := call.Arguments[0].String() - - // Validate domain - if err := r.validateDomain(urlStr); err != nil { - GoLog("[Extension:%s] fetch blocked: %v\n", r.extensionID, err) - return r.createFetchError(err.Error()) - } - - // Parse options - method := "GET" - var bodyStr string - headers := make(map[string]string) - - if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) { - optionsObj := call.Arguments[1].Export() - if opts, ok := optionsObj.(map[string]interface{}); ok { - // Method - if m, ok := opts["method"].(string); ok { - method = strings.ToUpper(m) - } - - // Body - support string, object (auto-stringify), or nil - if bodyArg, ok := opts["body"]; ok && bodyArg != nil { - switch v := bodyArg.(type) { - case string: - bodyStr = v - case map[string]interface{}, []interface{}: - jsonBytes, err := json.Marshal(v) - if err != nil { - return r.createFetchError(fmt.Sprintf("failed to stringify body: %v", err)) - } - bodyStr = string(jsonBytes) - default: - bodyStr = fmt.Sprintf("%v", v) - } - } - - // Headers - if h, ok := opts["headers"]; ok && h != nil { - switch hv := h.(type) { - case map[string]interface{}: - for k, v := range hv { - headers[k] = fmt.Sprintf("%v", v) - } - } - } - } - } - - // Create HTTP request - var reqBody io.Reader - if bodyStr != "" { - reqBody = strings.NewReader(bodyStr) - } - - req, err := http.NewRequest(method, urlStr, reqBody) - if err != nil { - return r.createFetchError(err.Error()) - } - - // Set headers - user headers first - for k, v := range headers { - req.Header.Set(k, v) - } - // Set defaults if not provided - if req.Header.Get("User-Agent") == "" { - req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0") - } - if bodyStr != "" && req.Header.Get("Content-Type") == "" { - req.Header.Set("Content-Type", "application/json") - } - - // Execute request - resp, err := r.httpClient.Do(req) - if err != nil { - return r.createFetchError(err.Error()) - } - defer resp.Body.Close() - - // Read body - body, err := io.ReadAll(resp.Body) - if err != nil { - return r.createFetchError(err.Error()) - } - - // Extract response headers - respHeaders := make(map[string]interface{}) - for k, v := range resp.Header { - if len(v) == 1 { - respHeaders[k] = v[0] - } else { - respHeaders[k] = v - } - } - - // Create Response object (browser-compatible) - responseObj := r.vm.NewObject() - responseObj.Set("ok", resp.StatusCode >= 200 && resp.StatusCode < 300) - responseObj.Set("status", resp.StatusCode) - responseObj.Set("statusText", http.StatusText(resp.StatusCode)) - responseObj.Set("headers", respHeaders) - responseObj.Set("url", urlStr) - - // Store body for methods - bodyString := string(body) - - // text() method - returns body as string - responseObj.Set("text", func(call goja.FunctionCall) goja.Value { - return r.vm.ToValue(bodyString) - }) - - // json() method - parses body as JSON - responseObj.Set("json", func(call goja.FunctionCall) goja.Value { - var result interface{} - if err := json.Unmarshal(body, &result); err != nil { - GoLog("[Extension:%s] fetch json() parse error: %v\n", r.extensionID, err) - return goja.Undefined() - } - return r.vm.ToValue(result) - }) - - // arrayBuffer() method - returns body as array (simplified) - responseObj.Set("arrayBuffer", func(call goja.FunctionCall) goja.Value { - // Return as array of bytes - byteArray := make([]interface{}, len(body)) - for i, b := range body { - byteArray[i] = int(b) - } - return r.vm.ToValue(byteArray) - }) - - return responseObj -} - -// createFetchError creates a fetch error response -func (r *ExtensionRuntime) createFetchError(message string) goja.Value { - errorObj := r.vm.NewObject() - errorObj.Set("ok", false) - errorObj.Set("status", 0) - errorObj.Set("statusText", "Network Error") - errorObj.Set("error", message) - errorObj.Set("text", func(call goja.FunctionCall) goja.Value { - return r.vm.ToValue("") - }) - errorObj.Set("json", func(call goja.FunctionCall) goja.Value { - return goja.Undefined() - }) - return errorObj -} - -// atobPolyfill implements browser atob() - decode base64 to string -func (r *ExtensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value { - if len(call.Arguments) < 1 { - return r.vm.ToValue("") - } - input := call.Arguments[0].String() - decoded, err := base64.StdEncoding.DecodeString(input) - if err != nil { - // Try URL-safe base64 - decoded, err = base64.URLEncoding.DecodeString(input) - if err != nil { - GoLog("[Extension:%s] atob decode error: %v\n", r.extensionID, err) - return r.vm.ToValue("") - } - } - return r.vm.ToValue(string(decoded)) -} - -// btoaPolyfill implements browser btoa() - encode string to base64 -func (r *ExtensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value { - if len(call.Arguments) < 1 { - return r.vm.ToValue("") - } - input := call.Arguments[0].String() - return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input))) -} - -// registerTextEncoderDecoder registers TextEncoder and TextDecoder classes -func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) { - // TextEncoder constructor - vm.Set("TextEncoder", func(call goja.ConstructorCall) *goja.Object { - encoder := call.This - encoder.Set("encoding", "utf-8") - - // encode() method - string to Uint8Array - encoder.Set("encode", func(call goja.FunctionCall) goja.Value { - if len(call.Arguments) < 1 { - return vm.ToValue([]byte{}) - } - input := call.Arguments[0].String() - bytes := []byte(input) - - // Return as array (Uint8Array-like) - result := make([]interface{}, len(bytes)) - for i, b := range bytes { - result[i] = int(b) - } - return vm.ToValue(result) - }) - - // encodeInto() method - encoder.Set("encodeInto", func(call goja.FunctionCall) goja.Value { - // Simplified implementation - if len(call.Arguments) < 2 { - return vm.ToValue(map[string]interface{}{"read": 0, "written": 0}) - } - input := call.Arguments[0].String() - return vm.ToValue(map[string]interface{}{ - "read": len(input), - "written": len([]byte(input)), - }) - }) - - return nil - }) - - // TextDecoder constructor - vm.Set("TextDecoder", func(call goja.ConstructorCall) *goja.Object { - decoder := call.This - - // Get encoding from arguments (default: utf-8) - encoding := "utf-8" - if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) { - encoding = call.Arguments[0].String() - } - decoder.Set("encoding", encoding) - decoder.Set("fatal", false) - decoder.Set("ignoreBOM", false) - - // decode() method - Uint8Array to string - decoder.Set("decode", func(call goja.FunctionCall) goja.Value { - if len(call.Arguments) < 1 { - return vm.ToValue("") - } - - // Handle different input types - input := call.Arguments[0].Export() - var bytes []byte - - switch v := input.(type) { - case []byte: - bytes = v - case []interface{}: - bytes = make([]byte, len(v)) - for i, val := range v { - switch n := val.(type) { - case int64: - bytes[i] = byte(n) - case float64: - bytes[i] = byte(n) - case int: - bytes[i] = byte(n) - } - } - case string: - // Already a string, just return it - return vm.ToValue(v) - default: - return vm.ToValue("") - } - - return vm.ToValue(string(bytes)) - }) - - return nil - }) -} - -// registerURLClass registers the URL class for URL parsing -func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) { - vm.Set("URL", func(call goja.ConstructorCall) *goja.Object { - urlObj := call.This - - if len(call.Arguments) < 1 { - urlObj.Set("href", "") - return nil - } - - urlStr := call.Arguments[0].String() - - // Handle relative URLs with base - if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) { - baseStr := call.Arguments[1].String() - baseURL, err := url.Parse(baseStr) - if err == nil { - relURL, err := url.Parse(urlStr) - if err == nil { - urlStr = baseURL.ResolveReference(relURL).String() - } - } - } - - parsed, err := url.Parse(urlStr) - if err != nil { - urlObj.Set("href", urlStr) - return nil - } - - // Set URL properties - urlObj.Set("href", parsed.String()) - urlObj.Set("protocol", parsed.Scheme+":") - urlObj.Set("host", parsed.Host) - urlObj.Set("hostname", parsed.Hostname()) - urlObj.Set("port", parsed.Port()) - urlObj.Set("pathname", parsed.Path) - urlObj.Set("search", "") - if parsed.RawQuery != "" { - urlObj.Set("search", "?"+parsed.RawQuery) - } - urlObj.Set("hash", "") - if parsed.Fragment != "" { - urlObj.Set("hash", "#"+parsed.Fragment) - } - urlObj.Set("origin", parsed.Scheme+"://"+parsed.Host) - urlObj.Set("username", parsed.User.Username()) - password, _ := parsed.User.Password() - urlObj.Set("password", password) - - // searchParams object - searchParams := vm.NewObject() - queryValues := parsed.Query() - - searchParams.Set("get", func(call goja.FunctionCall) goja.Value { - if len(call.Arguments) < 1 { - return goja.Null() - } - key := call.Arguments[0].String() - if val := queryValues.Get(key); val != "" { - return vm.ToValue(val) - } - return goja.Null() - }) - - searchParams.Set("getAll", func(call goja.FunctionCall) goja.Value { - if len(call.Arguments) < 1 { - return vm.ToValue([]string{}) - } - key := call.Arguments[0].String() - return vm.ToValue(queryValues[key]) - }) - - searchParams.Set("has", func(call goja.FunctionCall) goja.Value { - if len(call.Arguments) < 1 { - return vm.ToValue(false) - } - key := call.Arguments[0].String() - return vm.ToValue(queryValues.Has(key)) - }) - - searchParams.Set("toString", func(call goja.FunctionCall) goja.Value { - return vm.ToValue(queryValues.Encode()) - }) - - urlObj.Set("searchParams", searchParams) - - // toString method - urlObj.Set("toString", func(call goja.FunctionCall) goja.Value { - return vm.ToValue(parsed.String()) - }) - - // toJSON method - urlObj.Set("toJSON", func(call goja.FunctionCall) goja.Value { - return vm.ToValue(parsed.String()) - }) - - return nil - }) - - // URLSearchParams constructor - vm.Set("URLSearchParams", func(call goja.ConstructorCall) *goja.Object { - paramsObj := call.This - values := url.Values{} - - // Parse initial value if provided - if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) { - init := call.Arguments[0].Export() - switch v := init.(type) { - case string: - // Parse query string - parsed, _ := url.ParseQuery(strings.TrimPrefix(v, "?")) - values = parsed - case map[string]interface{}: - for k, val := range v { - values.Set(k, fmt.Sprintf("%v", val)) - } - } - } - - paramsObj.Set("append", func(call goja.FunctionCall) goja.Value { - if len(call.Arguments) >= 2 { - values.Add(call.Arguments[0].String(), call.Arguments[1].String()) - } - return goja.Undefined() - }) - - paramsObj.Set("delete", func(call goja.FunctionCall) goja.Value { - if len(call.Arguments) >= 1 { - values.Del(call.Arguments[0].String()) - } - return goja.Undefined() - }) - - paramsObj.Set("get", func(call goja.FunctionCall) goja.Value { - if len(call.Arguments) < 1 { - return goja.Null() - } - if val := values.Get(call.Arguments[0].String()); val != "" { - return vm.ToValue(val) - } - return goja.Null() - }) - - paramsObj.Set("getAll", func(call goja.FunctionCall) goja.Value { - if len(call.Arguments) < 1 { - return vm.ToValue([]string{}) - } - return vm.ToValue(values[call.Arguments[0].String()]) - }) - - paramsObj.Set("has", func(call goja.FunctionCall) goja.Value { - if len(call.Arguments) < 1 { - return vm.ToValue(false) - } - return vm.ToValue(values.Has(call.Arguments[0].String())) - }) - - paramsObj.Set("set", func(call goja.FunctionCall) goja.Value { - if len(call.Arguments) >= 2 { - values.Set(call.Arguments[0].String(), call.Arguments[1].String()) - } - return goja.Undefined() - }) - - paramsObj.Set("toString", func(call goja.FunctionCall) goja.Value { - return vm.ToValue(values.Encode()) - }) - - return nil - }) -} - -// registerJSONGlobal ensures JSON global is properly set up -func (r *ExtensionRuntime) registerJSONGlobal(vm *goja.Runtime) { - // JSON is already built-in to Goja, but we can enhance it - // This ensures JSON.parse and JSON.stringify work as expected - - // The built-in JSON object should already work, but let's verify - // and add any missing functionality if needed - jsonScript := ` - if (typeof JSON === 'undefined') { - var JSON = { - parse: function(text) { - return utils.parseJSON(text); - }, - stringify: function(value, replacer, space) { - return utils.stringifyJSON(value); - } - }; - } - ` - _, _ = vm.RunString(jsonScript) -} diff --git a/go_backend/extension_runtime_auth.go b/go_backend/extension_runtime_auth.go new file mode 100644 index 0000000..4e5102e --- /dev/null +++ b/go_backend/extension_runtime_auth.go @@ -0,0 +1,547 @@ +// Package gobackend provides Auth API and PKCE support for extension runtime +package gobackend + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/dop251/goja" +) + +// ==================== Auth API (OAuth Support) ==================== + +// authOpenUrl requests Flutter to open an OAuth URL +func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "auth URL is required", + }) + } + + authURL := call.Arguments[0].String() + callbackURL := "" + if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) { + callbackURL = call.Arguments[1].String() + } + + // Store pending auth request for Flutter to pick up + pendingAuthRequestsMu.Lock() + pendingAuthRequests[r.extensionID] = &PendingAuthRequest{ + ExtensionID: r.extensionID, + AuthURL: authURL, + CallbackURL: callbackURL, + } + pendingAuthRequestsMu.Unlock() + + // Update auth state + extensionAuthStateMu.Lock() + state, exists := extensionAuthState[r.extensionID] + if !exists { + state = &ExtensionAuthState{} + extensionAuthState[r.extensionID] = state + } + state.PendingAuthURL = authURL + state.AuthCode = "" // Clear any previous auth code + extensionAuthStateMu.Unlock() + + GoLog("[Extension:%s] Auth URL requested: %s\n", r.extensionID, authURL) + + return r.vm.ToValue(map[string]interface{}{ + "success": true, + "message": "Auth URL will be opened by the app", + }) +} + +// authGetCode gets the auth code (set by Flutter after OAuth callback) +func (r *ExtensionRuntime) authGetCode(call goja.FunctionCall) goja.Value { + extensionAuthStateMu.RLock() + defer extensionAuthStateMu.RUnlock() + + state, exists := extensionAuthState[r.extensionID] + if !exists || state.AuthCode == "" { + return goja.Undefined() + } + + return r.vm.ToValue(state.AuthCode) +} + +// authSetCode sets auth code and tokens (can be called by extension after token exchange) +func (r *ExtensionRuntime) authSetCode(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue(false) + } + + // Can accept either just auth code or an object with tokens + arg := call.Arguments[0].Export() + + extensionAuthStateMu.Lock() + defer extensionAuthStateMu.Unlock() + + state, exists := extensionAuthState[r.extensionID] + if !exists { + state = &ExtensionAuthState{} + extensionAuthState[r.extensionID] = state + } + + switch v := arg.(type) { + case string: + state.AuthCode = v + case map[string]interface{}: + if code, ok := v["code"].(string); ok { + state.AuthCode = code + } + if accessToken, ok := v["access_token"].(string); ok { + state.AccessToken = accessToken + state.IsAuthenticated = true + } + if refreshToken, ok := v["refresh_token"].(string); ok { + state.RefreshToken = refreshToken + } + if expiresIn, ok := v["expires_in"].(float64); ok { + state.ExpiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second) + } + } + + return r.vm.ToValue(true) +} + +// authClear clears all auth state for the extension +func (r *ExtensionRuntime) authClear(call goja.FunctionCall) goja.Value { + extensionAuthStateMu.Lock() + delete(extensionAuthState, r.extensionID) + extensionAuthStateMu.Unlock() + + pendingAuthRequestsMu.Lock() + delete(pendingAuthRequests, r.extensionID) + pendingAuthRequestsMu.Unlock() + + GoLog("[Extension:%s] Auth state cleared\n", r.extensionID) + return r.vm.ToValue(true) +} + +// authIsAuthenticated checks if extension has valid auth +func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Value { + extensionAuthStateMu.RLock() + defer extensionAuthStateMu.RUnlock() + + state, exists := extensionAuthState[r.extensionID] + if !exists { + return r.vm.ToValue(false) + } + + // Check if token is expired + if state.IsAuthenticated && !state.ExpiresAt.IsZero() && time.Now().After(state.ExpiresAt) { + return r.vm.ToValue(false) + } + + return r.vm.ToValue(state.IsAuthenticated) +} + +// authGetTokens returns current tokens (for extension to use in API calls) +func (r *ExtensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value { + extensionAuthStateMu.RLock() + defer extensionAuthStateMu.RUnlock() + + state, exists := extensionAuthState[r.extensionID] + if !exists { + return r.vm.ToValue(map[string]interface{}{}) + } + + result := map[string]interface{}{ + "access_token": state.AccessToken, + "refresh_token": state.RefreshToken, + "is_authenticated": state.IsAuthenticated, + } + + if !state.ExpiresAt.IsZero() { + result["expires_at"] = state.ExpiresAt.Unix() + result["is_expired"] = time.Now().After(state.ExpiresAt) + } + + return r.vm.ToValue(result) +} + +// ==================== PKCE Support ==================== + +// generatePKCEVerifier generates a cryptographically random code verifier +// Length should be between 43-128 characters (RFC 7636) +func generatePKCEVerifier(length int) (string, error) { + if length < 43 { + length = 43 + } + if length > 128 { + length = 128 + } + + // Generate random bytes + bytes := make([]byte, length) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + + // Use base64url encoding without padding (RFC 7636 compliant) + verifier := base64.RawURLEncoding.EncodeToString(bytes) + + // Trim to exact length + if len(verifier) > length { + verifier = verifier[:length] + } + + return verifier, nil +} + +// generatePKCEChallenge generates a code challenge from verifier using S256 method +func generatePKCEChallenge(verifier string) string { + hash := sha256.Sum256([]byte(verifier)) + // Base64url encode without padding (RFC 7636) + return base64.RawURLEncoding.EncodeToString(hash[:]) +} + +// authGeneratePKCE generates a PKCE code verifier and challenge pair +// Returns: { verifier: string, challenge: string, method: "S256" } +func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value { + // Default length is 64 characters + length := 64 + if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) { + if l, ok := call.Arguments[0].Export().(float64); ok && l >= 43 && l <= 128 { + length = int(l) + } + } + + verifier, err := generatePKCEVerifier(length) + if err != nil { + GoLog("[Extension:%s] PKCE generation error: %v\n", r.extensionID, err) + return r.vm.ToValue(map[string]interface{}{ + "error": err.Error(), + }) + } + + challenge := generatePKCEChallenge(verifier) + + // Store in auth state for later use + extensionAuthStateMu.Lock() + state, exists := extensionAuthState[r.extensionID] + if !exists { + state = &ExtensionAuthState{} + extensionAuthState[r.extensionID] = state + } + state.PKCEVerifier = verifier + state.PKCEChallenge = challenge + extensionAuthStateMu.Unlock() + + GoLog("[Extension:%s] PKCE generated (verifier length: %d)\n", r.extensionID, len(verifier)) + + return r.vm.ToValue(map[string]interface{}{ + "verifier": verifier, + "challenge": challenge, + "method": "S256", + }) +} + +// authGetPKCE returns the current PKCE verifier and challenge (if generated) +func (r *ExtensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value { + extensionAuthStateMu.RLock() + defer extensionAuthStateMu.RUnlock() + + state, exists := extensionAuthState[r.extensionID] + if !exists || state.PKCEVerifier == "" { + return r.vm.ToValue(map[string]interface{}{}) + } + + return r.vm.ToValue(map[string]interface{}{ + "verifier": state.PKCEVerifier, + "challenge": state.PKCEChallenge, + "method": "S256", + }) +} + +// authStartOAuthWithPKCE is a high-level helper that generates PKCE and opens OAuth URL +// config: { authUrl, clientId, redirectUri, scope, extraParams } +// Returns: { success, authUrl, pkce: { verifier, challenge } } +func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "config object is required", + }) + } + + configObj := call.Arguments[0].Export() + config, ok := configObj.(map[string]interface{}) + if !ok { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "config must be an object", + }) + } + + // Required fields + authURL, _ := config["authUrl"].(string) + clientID, _ := config["clientId"].(string) + redirectURI, _ := config["redirectUri"].(string) + + if authURL == "" || clientID == "" || redirectURI == "" { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "authUrl, clientId, and redirectUri are required", + }) + } + + // Optional fields + scope, _ := config["scope"].(string) + extraParams, _ := config["extraParams"].(map[string]interface{}) + + // Generate PKCE + verifier, err := generatePKCEVerifier(64) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": fmt.Sprintf("failed to generate PKCE: %v", err), + }) + } + challenge := generatePKCEChallenge(verifier) + + // Store PKCE in auth state + extensionAuthStateMu.Lock() + state, exists := extensionAuthState[r.extensionID] + if !exists { + state = &ExtensionAuthState{} + extensionAuthState[r.extensionID] = state + } + state.PKCEVerifier = verifier + state.PKCEChallenge = challenge + state.AuthCode = "" // Clear any previous auth code + extensionAuthStateMu.Unlock() + + // Build OAuth URL with PKCE parameters + parsedURL, err := url.Parse(authURL) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": fmt.Sprintf("invalid authUrl: %v", err), + }) + } + + query := parsedURL.Query() + query.Set("client_id", clientID) + query.Set("redirect_uri", redirectURI) + query.Set("response_type", "code") + query.Set("code_challenge", challenge) + query.Set("code_challenge_method", "S256") + + if scope != "" { + query.Set("scope", scope) + } + + // Add extra params + for k, v := range extraParams { + query.Set(k, fmt.Sprintf("%v", v)) + } + + parsedURL.RawQuery = query.Encode() + fullAuthURL := parsedURL.String() + + // Store pending auth request for Flutter + pendingAuthRequestsMu.Lock() + pendingAuthRequests[r.extensionID] = &PendingAuthRequest{ + ExtensionID: r.extensionID, + AuthURL: fullAuthURL, + CallbackURL: redirectURI, + } + pendingAuthRequestsMu.Unlock() + + GoLog("[Extension:%s] PKCE OAuth started: %s\n", r.extensionID, fullAuthURL) + + return r.vm.ToValue(map[string]interface{}{ + "success": true, + "authUrl": fullAuthURL, + "pkce": map[string]interface{}{ + "verifier": verifier, + "challenge": challenge, + "method": "S256", + }, + }) +} + +// authExchangeCodeWithPKCE exchanges auth code for tokens using PKCE +// config: { tokenUrl, clientId, redirectUri, code, extraParams } +// Uses the stored PKCE verifier automatically +func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "config object is required", + }) + } + + configObj := call.Arguments[0].Export() + config, ok := configObj.(map[string]interface{}) + if !ok { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "config must be an object", + }) + } + + // Required fields + tokenURL, _ := config["tokenUrl"].(string) + clientID, _ := config["clientId"].(string) + redirectURI, _ := config["redirectUri"].(string) + code, _ := config["code"].(string) + + if tokenURL == "" || clientID == "" || code == "" { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "tokenUrl, clientId, and code are required", + }) + } + + // Get stored PKCE verifier + extensionAuthStateMu.RLock() + state, exists := extensionAuthState[r.extensionID] + var verifier string + if exists { + verifier = state.PKCEVerifier + } + extensionAuthStateMu.RUnlock() + + if verifier == "" { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "no PKCE verifier found - call generatePKCE or startOAuthWithPKCE first", + }) + } + + // Validate domain + if err := r.validateDomain(tokenURL); err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + + // Build token request body + formData := url.Values{} + formData.Set("grant_type", "authorization_code") + formData.Set("client_id", clientID) + formData.Set("code", code) + formData.Set("code_verifier", verifier) + if redirectURI != "" { + formData.Set("redirect_uri", redirectURI) + } + + // Add extra params + if extraParams, ok := config["extraParams"].(map[string]interface{}); ok { + for k, v := range extraParams { + formData.Set(k, fmt.Sprintf("%v", v)) + } + } + + // Make token request + req, err := http.NewRequest("POST", tokenURL, strings.NewReader(formData.Encode())) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0") + + resp, err := r.httpClient.Do(req) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + + // Parse response + var tokenResp map[string]interface{} + if err := json.Unmarshal(body, &tokenResp); err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": fmt.Sprintf("failed to parse token response: %v", err), + "body": string(body), + }) + } + + // Check for error in response + if errMsg, ok := tokenResp["error"].(string); ok { + errDesc, _ := tokenResp["error_description"].(string) + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": errMsg, + "error_description": errDesc, + }) + } + + // Extract tokens + accessToken, _ := tokenResp["access_token"].(string) + refreshToken, _ := tokenResp["refresh_token"].(string) + expiresIn, _ := tokenResp["expires_in"].(float64) + + if accessToken == "" { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "no access_token in response", + "body": string(body), + }) + } + + // Store tokens in auth state + extensionAuthStateMu.Lock() + state, exists = extensionAuthState[r.extensionID] + if !exists { + state = &ExtensionAuthState{} + extensionAuthState[r.extensionID] = state + } + state.AccessToken = accessToken + state.RefreshToken = refreshToken + state.IsAuthenticated = true + if expiresIn > 0 { + state.ExpiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second) + } + // Clear PKCE after successful exchange + state.PKCEVerifier = "" + state.PKCEChallenge = "" + extensionAuthStateMu.Unlock() + + GoLog("[Extension:%s] PKCE token exchange successful\n", r.extensionID) + + // Return full token response + result := map[string]interface{}{ + "success": true, + "access_token": accessToken, + "refresh_token": refreshToken, + "token_type": tokenResp["token_type"], + } + if expiresIn > 0 { + result["expires_in"] = expiresIn + } + // Include any additional fields from response + if scope, ok := tokenResp["scope"].(string); ok { + result["scope"] = scope + } + + return r.vm.ToValue(result) +} diff --git a/go_backend/extension_runtime_ffmpeg.go b/go_backend/extension_runtime_ffmpeg.go new file mode 100644 index 0000000..889456b --- /dev/null +++ b/go_backend/extension_runtime_ffmpeg.go @@ -0,0 +1,204 @@ +// Package gobackend provides FFmpeg API for extension runtime +package gobackend + +import ( + "fmt" + "strings" + "sync" + "time" + + "github.com/dop251/goja" +) + +// ==================== FFmpeg API (Post-Processing) ==================== + +// FFmpegCommand holds a pending FFmpeg command for Flutter to execute +type FFmpegCommand struct { + ExtensionID string + Command string + InputPath string + OutputPath string + Completed bool + Success bool + Error string + Output string +} + +// Global FFmpeg command queue +var ( + ffmpegCommands = make(map[string]*FFmpegCommand) + ffmpegCommandsMu sync.RWMutex + ffmpegCommandID int64 +) + +// GetPendingFFmpegCommand returns a pending FFmpeg command (called from Flutter) +func GetPendingFFmpegCommand(commandID string) *FFmpegCommand { + ffmpegCommandsMu.RLock() + defer ffmpegCommandsMu.RUnlock() + return ffmpegCommands[commandID] +} + +// SetFFmpegCommandResult sets the result of an FFmpeg command (called from Flutter) +func SetFFmpegCommandResult(commandID string, success bool, output, errorMsg string) { + ffmpegCommandsMu.Lock() + defer ffmpegCommandsMu.Unlock() + if cmd, exists := ffmpegCommands[commandID]; exists { + cmd.Completed = true + cmd.Success = success + cmd.Output = output + cmd.Error = errorMsg + } +} + +// ClearFFmpegCommand removes a completed FFmpeg command +func ClearFFmpegCommand(commandID string) { + ffmpegCommandsMu.Lock() + defer ffmpegCommandsMu.Unlock() + delete(ffmpegCommands, commandID) +} + +// ffmpegExecute queues an FFmpeg command for execution by Flutter +func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "command is required", + }) + } + + command := call.Arguments[0].String() + + // Generate unique command ID + ffmpegCommandsMu.Lock() + ffmpegCommandID++ + cmdID := fmt.Sprintf("%s_%d", r.extensionID, ffmpegCommandID) + ffmpegCommands[cmdID] = &FFmpegCommand{ + ExtensionID: r.extensionID, + Command: command, + Completed: false, + } + ffmpegCommandsMu.Unlock() + + GoLog("[Extension:%s] FFmpeg command queued: %s\n", r.extensionID, cmdID) + + // Wait for completion (with timeout) + timeout := 5 * time.Minute + start := time.Now() + for { + ffmpegCommandsMu.RLock() + cmd := ffmpegCommands[cmdID] + completed := cmd != nil && cmd.Completed + ffmpegCommandsMu.RUnlock() + + if completed { + ffmpegCommandsMu.RLock() + result := map[string]interface{}{ + "success": cmd.Success, + "output": cmd.Output, + } + if cmd.Error != "" { + result["error"] = cmd.Error + } + ffmpegCommandsMu.RUnlock() + + // Cleanup + ClearFFmpegCommand(cmdID) + return r.vm.ToValue(result) + } + + if time.Since(start) > timeout { + ClearFFmpegCommand(cmdID) + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "FFmpeg command timed out", + }) + } + + time.Sleep(100 * time.Millisecond) + } +} + +// ffmpegGetInfo gets audio file information using FFprobe +func (r *ExtensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "file path is required", + }) + } + + filePath := call.Arguments[0].String() + + // Use Go's built-in audio quality function + quality, err := GetAudioQuality(filePath) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + + return r.vm.ToValue(map[string]interface{}{ + "success": true, + "bit_depth": quality.BitDepth, + "sample_rate": quality.SampleRate, + "total_samples": quality.TotalSamples, + "duration": float64(quality.TotalSamples) / float64(quality.SampleRate), + }) +} + +// ffmpegConvert is a helper for common conversion operations +func (r *ExtensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 2 { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "input and output paths are required", + }) + } + + inputPath := call.Arguments[0].String() + outputPath := call.Arguments[1].String() + + // Get options if provided + options := map[string]interface{}{} + if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) { + if opts, ok := call.Arguments[2].Export().(map[string]interface{}); ok { + options = opts + } + } + + // Build FFmpeg command + var cmdParts []string + cmdParts = append(cmdParts, "-i", fmt.Sprintf("%q", inputPath)) + + // Audio codec + if codec, ok := options["codec"].(string); ok { + cmdParts = append(cmdParts, "-c:a", codec) + } + + // Bitrate + if bitrate, ok := options["bitrate"].(string); ok { + cmdParts = append(cmdParts, "-b:a", bitrate) + } + + // Sample rate + if sampleRate, ok := options["sample_rate"].(float64); ok { + cmdParts = append(cmdParts, "-ar", fmt.Sprintf("%d", int(sampleRate))) + } + + // Channels + if channels, ok := options["channels"].(float64); ok { + cmdParts = append(cmdParts, "-ac", fmt.Sprintf("%d", int(channels))) + } + + // Overwrite output + cmdParts = append(cmdParts, "-y", fmt.Sprintf("%q", outputPath)) + + command := strings.Join(cmdParts, " ") + + // Execute via ffmpegExecute + execCall := goja.FunctionCall{ + Arguments: []goja.Value{r.vm.ToValue(command)}, + } + return r.ffmpegExecute(execCall) +} diff --git a/go_backend/extension_runtime_file.go b/go_backend/extension_runtime_file.go new file mode 100644 index 0000000..44b4ffd --- /dev/null +++ b/go_backend/extension_runtime_file.go @@ -0,0 +1,467 @@ +// Package gobackend provides File API for extension runtime +package gobackend + +import ( + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + + "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 +func (r *ExtensionRuntime) validatePath(path string) (string, error) { + // 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 + if filepath.IsAbs(cleanPath) { + return cleanPath, nil + } + + // For relative paths, join with data directory + fullPath := filepath.Join(r.dataDir, cleanPath) + + // Resolve to absolute path + absPath, err := filepath.Abs(fullPath) + if err != nil { + return "", fmt.Errorf("invalid path: %w", err) + } + + // Ensure path is within data directory + absDataDir, _ := filepath.Abs(r.dataDir) + if !strings.HasPrefix(absPath, absDataDir) { + return "", fmt.Errorf("file access denied: path '%s' is outside sandbox", path) + } + + return absPath, nil +} + +// fileDownload downloads a file from URL to the specified path +// Supports progress callback via options.onProgress +func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 2 { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "URL and output path are required", + }) + } + + urlStr := call.Arguments[0].String() + outputPath := call.Arguments[1].String() + + // Validate domain + if err := r.validateDomain(urlStr); err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + + // Validate output path (allows absolute paths for download queue) + fullPath, err := r.validatePath(outputPath) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + + // Get options if provided + var onProgress goja.Callable + var headers map[string]string + if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) { + optionsObj := call.Arguments[2].Export() + if opts, ok := optionsObj.(map[string]interface{}); ok { + // Extract headers + if h, ok := opts["headers"].(map[string]interface{}); ok { + headers = make(map[string]string) + for k, v := range h { + headers[k] = fmt.Sprintf("%v", v) + } + } + // Extract onProgress callback + if progressVal, ok := opts["onProgress"]; ok { + if callable, ok := goja.AssertFunction(r.vm.ToValue(progressVal)); ok { + onProgress = callable + } + } + } + } + + // Create directory if needed + dir := filepath.Dir(fullPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": fmt.Sprintf("failed to create directory: %v", err), + }) + } + + // Create HTTP request + req, err := http.NewRequest("GET", urlStr, nil) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + + // Set headers + for k, v := range headers { + req.Header.Set(k, v) + } + if req.Header.Get("User-Agent") == "" { + req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0") + } + + // Download file + resp, err := r.httpClient.Do(req) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": fmt.Sprintf("HTTP error: %d", resp.StatusCode), + }) + } + + // Create output file + out, err := os.Create(fullPath) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": fmt.Sprintf("failed to create file: %v", err), + }) + } + defer out.Close() + + // Get content length for progress + contentLength := resp.ContentLength + + // Copy content with progress reporting + var written int64 + buf := make([]byte, 32*1024) // 32KB buffer + for { + nr, er := resp.Body.Read(buf) + if nr > 0 { + nw, ew := out.Write(buf[0:nr]) + if nw < 0 || nr < nw { + nw = 0 + if ew == nil { + ew = fmt.Errorf("invalid write result") + } + } + written += int64(nw) + if ew != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": fmt.Sprintf("failed to write file: %v", ew), + }) + } + if nr != nw { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "short write", + }) + } + + // Report progress + if onProgress != nil && contentLength > 0 { + _, _ = onProgress(goja.Undefined(), r.vm.ToValue(written), r.vm.ToValue(contentLength)) + } + } + if er != nil { + if er != io.EOF { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": fmt.Sprintf("failed to read response: %v", er), + }) + } + break + } + } + + GoLog("[Extension:%s] Downloaded %d bytes to %s\n", r.extensionID, written, fullPath) + + return r.vm.ToValue(map[string]interface{}{ + "success": true, + "path": fullPath, + "size": written, + }) +} + +// fileExists checks if a file exists in the sandbox +func (r *ExtensionRuntime) fileExists(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue(false) + } + + path := call.Arguments[0].String() + fullPath, err := r.validatePath(path) + if err != nil { + return r.vm.ToValue(false) + } + + _, err = os.Stat(fullPath) + return r.vm.ToValue(err == nil) +} + +// fileDelete deletes a file in the sandbox +func (r *ExtensionRuntime) fileDelete(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "path is required", + }) + } + + path := call.Arguments[0].String() + fullPath, err := r.validatePath(path) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + + if err := os.Remove(fullPath); err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + + return r.vm.ToValue(map[string]interface{}{ + "success": true, + }) +} + +// fileRead reads a file from the sandbox +func (r *ExtensionRuntime) fileRead(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "path is required", + }) + } + + path := call.Arguments[0].String() + fullPath, err := r.validatePath(path) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + + data, err := os.ReadFile(fullPath) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + + return r.vm.ToValue(map[string]interface{}{ + "success": true, + "data": string(data), + }) +} + +// fileWrite writes data to a file in the sandbox +func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 2 { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "path and data are required", + }) + } + + path := call.Arguments[0].String() + data := call.Arguments[1].String() + + fullPath, err := r.validatePath(path) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + + // Create directory if needed + dir := filepath.Dir(fullPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": fmt.Sprintf("failed to create directory: %v", err), + }) + } + + if err := os.WriteFile(fullPath, []byte(data), 0644); err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + + return r.vm.ToValue(map[string]interface{}{ + "success": true, + "path": fullPath, + }) +} + +// fileCopy copies a file within the sandbox +func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 2 { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "source and destination paths are required", + }) + } + + srcPath := call.Arguments[0].String() + dstPath := call.Arguments[1].String() + + fullSrc, err := r.validatePath(srcPath) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + + fullDst, err := r.validatePath(dstPath) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + + // Read source file + data, err := os.ReadFile(fullSrc) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": fmt.Sprintf("failed to read source: %v", err), + }) + } + + // Create destination directory if needed + dir := filepath.Dir(fullDst) + if err := os.MkdirAll(dir, 0755); err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": fmt.Sprintf("failed to create directory: %v", err), + }) + } + + // Write to destination + if err := os.WriteFile(fullDst, data, 0644); err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": fmt.Sprintf("failed to write destination: %v", err), + }) + } + + return r.vm.ToValue(map[string]interface{}{ + "success": true, + "path": fullDst, + }) +} + +// fileMove moves/renames a file within the sandbox +func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 2 { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "source and destination paths are required", + }) + } + + srcPath := call.Arguments[0].String() + dstPath := call.Arguments[1].String() + + fullSrc, err := r.validatePath(srcPath) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + + fullDst, err := r.validatePath(dstPath) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + + // Create destination directory if needed + dir := filepath.Dir(fullDst) + if err := os.MkdirAll(dir, 0755); err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": fmt.Sprintf("failed to create directory: %v", err), + }) + } + + if err := os.Rename(fullSrc, fullDst); err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": fmt.Sprintf("failed to move file: %v", err), + }) + } + + return r.vm.ToValue(map[string]interface{}{ + "success": true, + "path": fullDst, + }) +} + +// fileGetSize returns the size of a file in bytes +func (r *ExtensionRuntime) fileGetSize(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "path is required", + }) + } + + path := call.Arguments[0].String() + fullPath, err := r.validatePath(path) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + + info, err := os.Stat(fullPath) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + + return r.vm.ToValue(map[string]interface{}{ + "success": true, + "size": info.Size(), + }) +} diff --git a/go_backend/extension_runtime_http.go b/go_backend/extension_runtime_http.go new file mode 100644 index 0000000..c27bd7f --- /dev/null +++ b/go_backend/extension_runtime_http.go @@ -0,0 +1,499 @@ +// Package gobackend provides HTTP API for extension runtime +package gobackend + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/dop251/goja" +) + +// ==================== HTTP API (Sandboxed) ==================== + +// HTTPResponse represents the response from an HTTP request +type HTTPResponse struct { + StatusCode int `json:"statusCode"` + Body string `json:"body"` + Headers map[string]string `json:"headers"` +} + +// validateDomain checks if the domain is allowed by the extension's permissions +func (r *ExtensionRuntime) validateDomain(urlStr string) error { + parsed, err := url.Parse(urlStr) + if err != nil { + return fmt.Errorf("invalid URL: %w", err) + } + + domain := parsed.Hostname() + if !r.manifest.IsDomainAllowed(domain) { + return fmt.Errorf("network access denied: domain '%s' not in allowed list", domain) + } + + return nil +} + +// httpGet performs a GET request (sandboxed) +func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue(map[string]interface{}{ + "error": "URL is required", + }) + } + + urlStr := call.Arguments[0].String() + + // Validate domain + if err := r.validateDomain(urlStr); err != nil { + GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err) + return r.vm.ToValue(map[string]interface{}{ + "error": err.Error(), + }) + } + + // Get headers if provided + headers := make(map[string]string) + if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) { + headersObj := call.Arguments[1].Export() + if h, ok := headersObj.(map[string]interface{}); ok { + for k, v := range h { + headers[k] = fmt.Sprintf("%v", v) + } + } + } + + // Create request + req, err := http.NewRequest("GET", urlStr, nil) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "error": err.Error(), + }) + } + + // Set headers - user headers first + for k, v := range headers { + req.Header.Set(k, v) + } + // Only set default User-Agent if not provided by extension + if req.Header.Get("User-Agent") == "" { + req.Header.Set("User-Agent", "Spotiflac-Extension/1.0") + } + + // Execute request + resp, err := r.httpClient.Do(req) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "error": err.Error(), + }) + } + defer resp.Body.Close() + + // Read body + body, err := io.ReadAll(resp.Body) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "error": err.Error(), + }) + } + + // Extract response headers - return all values as arrays for multi-value headers (cookies, etc.) + respHeaders := make(map[string]interface{}) + for k, v := range resp.Header { + if len(v) == 1 { + respHeaders[k] = v[0] + } else { + respHeaders[k] = v // Return as array if multiple values + } + } + + return r.vm.ToValue(map[string]interface{}{ + "statusCode": resp.StatusCode, + "status": resp.StatusCode, // Alias for convenience + "ok": resp.StatusCode >= 200 && resp.StatusCode < 300, + "body": string(body), + "headers": respHeaders, + }) +} + +// httpPost performs a POST request (sandboxed) +func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue(map[string]interface{}{ + "error": "URL is required", + }) + } + + urlStr := call.Arguments[0].String() + + // Validate domain + if err := r.validateDomain(urlStr); err != nil { + GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err) + return r.vm.ToValue(map[string]interface{}{ + "error": err.Error(), + }) + } + + // Get body if provided - support both string and object + var bodyStr string + if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) { + bodyArg := call.Arguments[1].Export() + switch v := bodyArg.(type) { + case string: + bodyStr = v + case map[string]interface{}, []interface{}: + // Auto-stringify objects and arrays to JSON + jsonBytes, err := json.Marshal(v) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "error": fmt.Sprintf("failed to stringify body: %v", err), + }) + } + bodyStr = string(jsonBytes) + default: + // Fallback to string conversion + bodyStr = call.Arguments[1].String() + } + } + + // Get headers if provided + headers := make(map[string]string) + if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) { + headersObj := call.Arguments[2].Export() + if h, ok := headersObj.(map[string]interface{}); ok { + for k, v := range h { + headers[k] = fmt.Sprintf("%v", v) + } + } + } + + // Create request + req, err := http.NewRequest("POST", urlStr, strings.NewReader(bodyStr)) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "error": err.Error(), + }) + } + + // Set headers - user headers first + for k, v := range headers { + req.Header.Set(k, v) + } + // Only set defaults if not provided by extension + if req.Header.Get("User-Agent") == "" { + req.Header.Set("User-Agent", "Spotiflac-Extension/1.0") + } + if req.Header.Get("Content-Type") == "" { + req.Header.Set("Content-Type", "application/json") + } + + // Execute request + resp, err := r.httpClient.Do(req) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "error": err.Error(), + }) + } + defer resp.Body.Close() + + // Read body + body, err := io.ReadAll(resp.Body) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "error": err.Error(), + }) + } + + // Extract response headers - return all values as arrays for multi-value headers + respHeaders := make(map[string]interface{}) + for k, v := range resp.Header { + if len(v) == 1 { + respHeaders[k] = v[0] + } else { + respHeaders[k] = v // Return as array if multiple values + } + } + + return r.vm.ToValue(map[string]interface{}{ + "statusCode": resp.StatusCode, + "status": resp.StatusCode, // Alias for convenience + "ok": resp.StatusCode >= 200 && resp.StatusCode < 300, + "body": string(body), + "headers": respHeaders, + }) +} + +// httpRequest performs a generic HTTP request (GET, POST, PUT, DELETE, etc.) +// Usage: http.request(url, options) where options = { method, body, headers } +func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue(map[string]interface{}{ + "error": "URL is required", + }) + } + + urlStr := call.Arguments[0].String() + + // Validate domain + if err := r.validateDomain(urlStr); err != nil { + GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err) + return r.vm.ToValue(map[string]interface{}{ + "error": err.Error(), + }) + } + + // Default options + method := "GET" + var bodyStr string + headers := make(map[string]string) + + // Parse options if provided + if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) { + optionsObj := call.Arguments[1].Export() + if opts, ok := optionsObj.(map[string]interface{}); ok { + // Get method + if m, ok := opts["method"].(string); ok { + method = strings.ToUpper(m) + } + + // Get body - support both string and object + if bodyArg, ok := opts["body"]; ok && bodyArg != nil { + switch v := bodyArg.(type) { + case string: + bodyStr = v + case map[string]interface{}, []interface{}: + // Auto-stringify objects and arrays to JSON + jsonBytes, err := json.Marshal(v) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "error": fmt.Sprintf("failed to stringify body: %v", err), + }) + } + bodyStr = string(jsonBytes) + default: + bodyStr = fmt.Sprintf("%v", v) + } + } + + // Get headers + if h, ok := opts["headers"].(map[string]interface{}); ok { + for k, v := range h { + headers[k] = fmt.Sprintf("%v", v) + } + } + } + } + + // Create request + var reqBody io.Reader + if bodyStr != "" { + reqBody = strings.NewReader(bodyStr) + } + + req, err := http.NewRequest(method, urlStr, reqBody) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "error": err.Error(), + }) + } + + // Set headers - user headers first + for k, v := range headers { + req.Header.Set(k, v) + } + // Only set defaults if not provided by extension + if req.Header.Get("User-Agent") == "" { + req.Header.Set("User-Agent", "Spotiflac-Extension/1.0") + } + if bodyStr != "" && req.Header.Get("Content-Type") == "" { + req.Header.Set("Content-Type", "application/json") + } + + // Execute request + resp, err := r.httpClient.Do(req) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "error": err.Error(), + }) + } + defer resp.Body.Close() + + // Read body + body, err := io.ReadAll(resp.Body) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "error": err.Error(), + }) + } + + // Extract response headers - return all values as arrays for multi-value headers + respHeaders := make(map[string]interface{}) + for k, v := range resp.Header { + if len(v) == 1 { + respHeaders[k] = v[0] + } else { + respHeaders[k] = v // Return as array if multiple values + } + } + + // Return response with helper properties + return r.vm.ToValue(map[string]interface{}{ + "statusCode": resp.StatusCode, + "status": resp.StatusCode, // Alias for convenience + "ok": resp.StatusCode >= 200 && resp.StatusCode < 300, + "body": string(body), + "headers": respHeaders, + }) +} + +// httpPut performs a PUT request (shortcut for http.request with method: "PUT") +func (r *ExtensionRuntime) httpPut(call goja.FunctionCall) goja.Value { + return r.httpMethodShortcut("PUT", call) +} + +// httpDelete performs a DELETE request (shortcut for http.request with method: "DELETE") +func (r *ExtensionRuntime) httpDelete(call goja.FunctionCall) goja.Value { + return r.httpMethodShortcut("DELETE", call) +} + +// httpPatch performs a PATCH request (shortcut for http.request with method: "PATCH") +func (r *ExtensionRuntime) httpPatch(call goja.FunctionCall) goja.Value { + return r.httpMethodShortcut("PATCH", call) +} + +// httpMethodShortcut is a helper for PUT/DELETE/PATCH shortcuts +// Signature: http.put(url, body, headers) / http.delete(url, headers) / http.patch(url, body, headers) +func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue(map[string]interface{}{ + "error": "URL is required", + }) + } + + urlStr := call.Arguments[0].String() + + // Validate domain + if err := r.validateDomain(urlStr); err != nil { + GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err) + return r.vm.ToValue(map[string]interface{}{ + "error": err.Error(), + }) + } + + var bodyStr string + headers := make(map[string]string) + + // For DELETE, second arg is headers; for PUT/PATCH, second arg is body + if method == "DELETE" { + // http.delete(url, headers) + if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) { + headersObj := call.Arguments[1].Export() + if h, ok := headersObj.(map[string]interface{}); ok { + for k, v := range h { + headers[k] = fmt.Sprintf("%v", v) + } + } + } + } else { + // http.put(url, body, headers) / http.patch(url, body, headers) + if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) { + bodyArg := call.Arguments[1].Export() + switch v := bodyArg.(type) { + case string: + bodyStr = v + case map[string]interface{}, []interface{}: + jsonBytes, err := json.Marshal(v) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "error": fmt.Sprintf("failed to stringify body: %v", err), + }) + } + bodyStr = string(jsonBytes) + default: + bodyStr = call.Arguments[1].String() + } + } + + if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) { + headersObj := call.Arguments[2].Export() + if h, ok := headersObj.(map[string]interface{}); ok { + for k, v := range h { + headers[k] = fmt.Sprintf("%v", v) + } + } + } + } + + // Create request + var reqBody io.Reader + if bodyStr != "" { + reqBody = strings.NewReader(bodyStr) + } + + req, err := http.NewRequest(method, urlStr, reqBody) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "error": err.Error(), + }) + } + + // Set headers - user headers first + for k, v := range headers { + req.Header.Set(k, v) + } + if req.Header.Get("User-Agent") == "" { + req.Header.Set("User-Agent", "Spotiflac-Extension/1.0") + } + if bodyStr != "" && req.Header.Get("Content-Type") == "" { + req.Header.Set("Content-Type", "application/json") + } + + // Execute request + resp, err := r.httpClient.Do(req) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "error": err.Error(), + }) + } + defer resp.Body.Close() + + // Read body + body, err := io.ReadAll(resp.Body) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "error": err.Error(), + }) + } + + // Extract response headers + respHeaders := make(map[string]interface{}) + for k, v := range resp.Header { + if len(v) == 1 { + respHeaders[k] = v[0] + } else { + respHeaders[k] = v + } + } + + return r.vm.ToValue(map[string]interface{}{ + "statusCode": resp.StatusCode, + "status": resp.StatusCode, + "ok": resp.StatusCode >= 200 && resp.StatusCode < 300, + "body": string(body), + "headers": respHeaders, + }) +} + +// httpClearCookies clears all cookies for this extension +func (r *ExtensionRuntime) httpClearCookies(call goja.FunctionCall) goja.Value { + if jar, ok := r.cookieJar.(*simpleCookieJar); ok { + jar.mu.Lock() + jar.cookies = make(map[string][]*http.Cookie) + jar.mu.Unlock() + GoLog("[Extension:%s] Cookies cleared\n", r.extensionID) + return r.vm.ToValue(true) + } + return r.vm.ToValue(false) +} diff --git a/go_backend/extension_runtime_matching.go b/go_backend/extension_runtime_matching.go new file mode 100644 index 0000000..9e56fa8 --- /dev/null +++ b/go_backend/extension_runtime_matching.go @@ -0,0 +1,151 @@ +// Package gobackend provides Track Matching API for extension runtime +package gobackend + +import ( + "strings" + + "github.com/dop251/goja" +) + +// ==================== Track Matching API ==================== + +// matchingCompareStrings compares two strings with fuzzy matching +func (r *ExtensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 2 { + return r.vm.ToValue(0.0) + } + + str1 := strings.ToLower(strings.TrimSpace(call.Arguments[0].String())) + str2 := strings.ToLower(strings.TrimSpace(call.Arguments[1].String())) + + if str1 == str2 { + return r.vm.ToValue(1.0) + } + + // Calculate Levenshtein distance-based similarity + similarity := calculateStringSimilarity(str1, str2) + return r.vm.ToValue(similarity) +} + +// matchingCompareDuration compares two durations with tolerance +func (r *ExtensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 2 { + return r.vm.ToValue(false) + } + + dur1 := int(call.Arguments[0].ToInteger()) + dur2 := int(call.Arguments[1].ToInteger()) + + // Default tolerance: 3 seconds + tolerance := 3000 // milliseconds + if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) { + tolerance = int(call.Arguments[2].ToInteger()) + } + + diff := dur1 - dur2 + if diff < 0 { + diff = -diff + } + + return r.vm.ToValue(diff <= tolerance) +} + +// matchingNormalizeString normalizes a string for comparison +func (r *ExtensionRuntime) matchingNormalizeString(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue("") + } + + str := call.Arguments[0].String() + normalized := normalizeStringForMatching(str) + return r.vm.ToValue(normalized) +} + +// calculateStringSimilarity calculates similarity between two strings (0-1) +func calculateStringSimilarity(s1, s2 string) float64 { + if len(s1) == 0 && len(s2) == 0 { + return 1.0 + } + if len(s1) == 0 || len(s2) == 0 { + return 0.0 + } + + // Use Levenshtein distance + distance := levenshteinDistance(s1, s2) + maxLen := len(s1) + if len(s2) > maxLen { + maxLen = len(s2) + } + + return 1.0 - float64(distance)/float64(maxLen) +} + +// levenshteinDistance calculates the Levenshtein distance between two strings +func levenshteinDistance(s1, s2 string) int { + if len(s1) == 0 { + return len(s2) + } + if len(s2) == 0 { + return len(s1) + } + + // Create matrix + matrix := make([][]int, len(s1)+1) + for i := range matrix { + matrix[i] = make([]int, len(s2)+1) + matrix[i][0] = i + } + for j := range matrix[0] { + matrix[0][j] = j + } + + // Fill matrix + for i := 1; i <= len(s1); i++ { + for j := 1; j <= len(s2); j++ { + cost := 1 + if s1[i-1] == s2[j-1] { + cost = 0 + } + matrix[i][j] = min( + matrix[i-1][j]+1, // deletion + matrix[i][j-1]+1, // insertion + matrix[i-1][j-1]+cost, // substitution + ) + } + } + + return matrix[len(s1)][len(s2)] +} + +// normalizeStringForMatching normalizes a string for comparison +func normalizeStringForMatching(s string) string { + // Convert to lowercase + s = strings.ToLower(s) + + // Remove common suffixes/prefixes + suffixes := []string{ + " (remastered)", " (remaster)", " - remastered", " - remaster", + " (deluxe)", " (deluxe edition)", " - deluxe", " - deluxe edition", + " (explicit)", " (clean)", " [explicit]", " [clean]", + " (album version)", " (single version)", " (radio edit)", + " (feat.", " (ft.", " feat.", " ft.", + } + for _, suffix := range suffixes { + if idx := strings.Index(s, suffix); idx != -1 { + s = s[:idx] + } + } + + // Remove special characters + var result strings.Builder + for _, r := range s { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == ' ' { + result.WriteRune(r) + } + } + + // Collapse multiple spaces + s = strings.Join(strings.Fields(result.String()), " ") + + return strings.TrimSpace(s) +} diff --git a/go_backend/extension_runtime_polyfills.go b/go_backend/extension_runtime_polyfills.go new file mode 100644 index 0000000..5293841 --- /dev/null +++ b/go_backend/extension_runtime_polyfills.go @@ -0,0 +1,488 @@ +// Package gobackend provides Browser-like Polyfills for extension runtime +package gobackend + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/dop251/goja" +) + +// ==================== Browser-like Polyfills ==================== +// These polyfills make porting browser/Node.js libraries easier +// without compromising sandbox security + +// fetchPolyfill implements browser-compatible fetch() API +// Returns a Promise-like object with json(), text() methods +func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.createFetchError("URL is required") + } + + urlStr := call.Arguments[0].String() + + // Validate domain + if err := r.validateDomain(urlStr); err != nil { + GoLog("[Extension:%s] fetch blocked: %v\n", r.extensionID, err) + return r.createFetchError(err.Error()) + } + + // Parse options + method := "GET" + var bodyStr string + headers := make(map[string]string) + + if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) { + optionsObj := call.Arguments[1].Export() + if opts, ok := optionsObj.(map[string]interface{}); ok { + // Method + if m, ok := opts["method"].(string); ok { + method = strings.ToUpper(m) + } + + // Body - support string, object (auto-stringify), or nil + if bodyArg, ok := opts["body"]; ok && bodyArg != nil { + switch v := bodyArg.(type) { + case string: + bodyStr = v + case map[string]interface{}, []interface{}: + jsonBytes, err := json.Marshal(v) + if err != nil { + return r.createFetchError(fmt.Sprintf("failed to stringify body: %v", err)) + } + bodyStr = string(jsonBytes) + default: + bodyStr = fmt.Sprintf("%v", v) + } + } + + // Headers + if h, ok := opts["headers"]; ok && h != nil { + switch hv := h.(type) { + case map[string]interface{}: + for k, v := range hv { + headers[k] = fmt.Sprintf("%v", v) + } + } + } + } + } + + // Create HTTP request + var reqBody io.Reader + if bodyStr != "" { + reqBody = strings.NewReader(bodyStr) + } + + req, err := http.NewRequest(method, urlStr, reqBody) + if err != nil { + return r.createFetchError(err.Error()) + } + + // Set headers - user headers first + for k, v := range headers { + req.Header.Set(k, v) + } + // Set defaults if not provided + if req.Header.Get("User-Agent") == "" { + req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0") + } + if bodyStr != "" && req.Header.Get("Content-Type") == "" { + req.Header.Set("Content-Type", "application/json") + } + + // Execute request + resp, err := r.httpClient.Do(req) + if err != nil { + return r.createFetchError(err.Error()) + } + defer resp.Body.Close() + + // Read body + body, err := io.ReadAll(resp.Body) + if err != nil { + return r.createFetchError(err.Error()) + } + + // Extract response headers + respHeaders := make(map[string]interface{}) + for k, v := range resp.Header { + if len(v) == 1 { + respHeaders[k] = v[0] + } else { + respHeaders[k] = v + } + } + + // Create Response object (browser-compatible) + responseObj := r.vm.NewObject() + responseObj.Set("ok", resp.StatusCode >= 200 && resp.StatusCode < 300) + responseObj.Set("status", resp.StatusCode) + responseObj.Set("statusText", http.StatusText(resp.StatusCode)) + responseObj.Set("headers", respHeaders) + responseObj.Set("url", urlStr) + + // Store body for methods + bodyString := string(body) + + // text() method - returns body as string + responseObj.Set("text", func(call goja.FunctionCall) goja.Value { + return r.vm.ToValue(bodyString) + }) + + // json() method - parses body as JSON + responseObj.Set("json", func(call goja.FunctionCall) goja.Value { + var result interface{} + if err := json.Unmarshal(body, &result); err != nil { + GoLog("[Extension:%s] fetch json() parse error: %v\n", r.extensionID, err) + return goja.Undefined() + } + return r.vm.ToValue(result) + }) + + // arrayBuffer() method - returns body as array (simplified) + responseObj.Set("arrayBuffer", func(call goja.FunctionCall) goja.Value { + // Return as array of bytes + byteArray := make([]interface{}, len(body)) + for i, b := range body { + byteArray[i] = int(b) + } + return r.vm.ToValue(byteArray) + }) + + return responseObj +} + +// createFetchError creates a fetch error response +func (r *ExtensionRuntime) createFetchError(message string) goja.Value { + errorObj := r.vm.NewObject() + errorObj.Set("ok", false) + errorObj.Set("status", 0) + errorObj.Set("statusText", "Network Error") + errorObj.Set("error", message) + errorObj.Set("text", func(call goja.FunctionCall) goja.Value { + return r.vm.ToValue("") + }) + errorObj.Set("json", func(call goja.FunctionCall) goja.Value { + return goja.Undefined() + }) + return errorObj +} + +// atobPolyfill implements browser atob() - decode base64 to string +func (r *ExtensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue("") + } + input := call.Arguments[0].String() + decoded, err := base64.StdEncoding.DecodeString(input) + if err != nil { + // Try URL-safe base64 + decoded, err = base64.URLEncoding.DecodeString(input) + if err != nil { + GoLog("[Extension:%s] atob decode error: %v\n", r.extensionID, err) + return r.vm.ToValue("") + } + } + return r.vm.ToValue(string(decoded)) +} + +// btoaPolyfill implements browser btoa() - encode string to base64 +func (r *ExtensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue("") + } + input := call.Arguments[0].String() + return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input))) +} + +// registerTextEncoderDecoder registers TextEncoder and TextDecoder classes +func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) { + // TextEncoder constructor + vm.Set("TextEncoder", func(call goja.ConstructorCall) *goja.Object { + encoder := call.This + encoder.Set("encoding", "utf-8") + + // encode() method - string to Uint8Array + encoder.Set("encode", func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return vm.ToValue([]byte{}) + } + input := call.Arguments[0].String() + bytes := []byte(input) + + // Return as array (Uint8Array-like) + result := make([]interface{}, len(bytes)) + for i, b := range bytes { + result[i] = int(b) + } + return vm.ToValue(result) + }) + + // encodeInto() method + encoder.Set("encodeInto", func(call goja.FunctionCall) goja.Value { + // Simplified implementation + if len(call.Arguments) < 2 { + return vm.ToValue(map[string]interface{}{"read": 0, "written": 0}) + } + input := call.Arguments[0].String() + return vm.ToValue(map[string]interface{}{ + "read": len(input), + "written": len([]byte(input)), + }) + }) + + return nil + }) + + // TextDecoder constructor + vm.Set("TextDecoder", func(call goja.ConstructorCall) *goja.Object { + decoder := call.This + + // Get encoding from arguments (default: utf-8) + encoding := "utf-8" + if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) { + encoding = call.Arguments[0].String() + } + decoder.Set("encoding", encoding) + decoder.Set("fatal", false) + decoder.Set("ignoreBOM", false) + + // decode() method - Uint8Array to string + decoder.Set("decode", func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return vm.ToValue("") + } + + // Handle different input types + input := call.Arguments[0].Export() + var bytes []byte + + switch v := input.(type) { + case []byte: + bytes = v + case []interface{}: + bytes = make([]byte, len(v)) + for i, val := range v { + switch n := val.(type) { + case int64: + bytes[i] = byte(n) + case float64: + bytes[i] = byte(n) + case int: + bytes[i] = byte(n) + } + } + case string: + // Already a string, just return it + return vm.ToValue(v) + default: + return vm.ToValue("") + } + + return vm.ToValue(string(bytes)) + }) + + return nil + }) +} + +// registerURLClass registers the URL class for URL parsing +func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) { + vm.Set("URL", func(call goja.ConstructorCall) *goja.Object { + urlObj := call.This + + if len(call.Arguments) < 1 { + urlObj.Set("href", "") + return nil + } + + urlStr := call.Arguments[0].String() + + // Handle relative URLs with base + if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) { + baseStr := call.Arguments[1].String() + baseURL, err := url.Parse(baseStr) + if err == nil { + relURL, err := url.Parse(urlStr) + if err == nil { + urlStr = baseURL.ResolveReference(relURL).String() + } + } + } + + parsed, err := url.Parse(urlStr) + if err != nil { + urlObj.Set("href", urlStr) + return nil + } + + // Set URL properties + urlObj.Set("href", parsed.String()) + urlObj.Set("protocol", parsed.Scheme+":") + urlObj.Set("host", parsed.Host) + urlObj.Set("hostname", parsed.Hostname()) + urlObj.Set("port", parsed.Port()) + urlObj.Set("pathname", parsed.Path) + urlObj.Set("search", "") + if parsed.RawQuery != "" { + urlObj.Set("search", "?"+parsed.RawQuery) + } + urlObj.Set("hash", "") + if parsed.Fragment != "" { + urlObj.Set("hash", "#"+parsed.Fragment) + } + urlObj.Set("origin", parsed.Scheme+"://"+parsed.Host) + urlObj.Set("username", parsed.User.Username()) + password, _ := parsed.User.Password() + urlObj.Set("password", password) + + // searchParams object + searchParams := vm.NewObject() + queryValues := parsed.Query() + + searchParams.Set("get", func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return goja.Null() + } + key := call.Arguments[0].String() + if val := queryValues.Get(key); val != "" { + return vm.ToValue(val) + } + return goja.Null() + }) + + searchParams.Set("getAll", func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return vm.ToValue([]string{}) + } + key := call.Arguments[0].String() + return vm.ToValue(queryValues[key]) + }) + + searchParams.Set("has", func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return vm.ToValue(false) + } + key := call.Arguments[0].String() + return vm.ToValue(queryValues.Has(key)) + }) + + searchParams.Set("toString", func(call goja.FunctionCall) goja.Value { + return vm.ToValue(queryValues.Encode()) + }) + + urlObj.Set("searchParams", searchParams) + + // toString method + urlObj.Set("toString", func(call goja.FunctionCall) goja.Value { + return vm.ToValue(parsed.String()) + }) + + // toJSON method + urlObj.Set("toJSON", func(call goja.FunctionCall) goja.Value { + return vm.ToValue(parsed.String()) + }) + + return nil + }) + + // URLSearchParams constructor + vm.Set("URLSearchParams", func(call goja.ConstructorCall) *goja.Object { + paramsObj := call.This + values := url.Values{} + + // Parse initial value if provided + if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) { + init := call.Arguments[0].Export() + switch v := init.(type) { + case string: + // Parse query string + parsed, _ := url.ParseQuery(strings.TrimPrefix(v, "?")) + values = parsed + case map[string]interface{}: + for k, val := range v { + values.Set(k, fmt.Sprintf("%v", val)) + } + } + } + + paramsObj.Set("append", func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) >= 2 { + values.Add(call.Arguments[0].String(), call.Arguments[1].String()) + } + return goja.Undefined() + }) + + paramsObj.Set("delete", func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) >= 1 { + values.Del(call.Arguments[0].String()) + } + return goja.Undefined() + }) + + paramsObj.Set("get", func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return goja.Null() + } + if val := values.Get(call.Arguments[0].String()); val != "" { + return vm.ToValue(val) + } + return goja.Null() + }) + + paramsObj.Set("getAll", func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return vm.ToValue([]string{}) + } + return vm.ToValue(values[call.Arguments[0].String()]) + }) + + paramsObj.Set("has", func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return vm.ToValue(false) + } + return vm.ToValue(values.Has(call.Arguments[0].String())) + }) + + paramsObj.Set("set", func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) >= 2 { + values.Set(call.Arguments[0].String(), call.Arguments[1].String()) + } + return goja.Undefined() + }) + + paramsObj.Set("toString", func(call goja.FunctionCall) goja.Value { + return vm.ToValue(values.Encode()) + }) + + return nil + }) +} + +// registerJSONGlobal ensures JSON global is properly set up +func (r *ExtensionRuntime) registerJSONGlobal(vm *goja.Runtime) { + // JSON is already built-in to Goja, but we can enhance it + // This ensures JSON.parse and JSON.stringify work as expected + + // The built-in JSON object should already work, but let's verify + // and add any missing functionality if needed + jsonScript := ` + if (typeof JSON === 'undefined') { + var JSON = { + parse: function(text) { + return utils.parseJSON(text); + }, + stringify: function(value, replacer, space) { + return utils.stringifyJSON(value); + } + }; + } + ` + _, _ = vm.RunString(jsonScript) +} diff --git a/go_backend/extension_runtime_storage.go b/go_backend/extension_runtime_storage.go new file mode 100644 index 0000000..54882a4 --- /dev/null +++ b/go_backend/extension_runtime_storage.go @@ -0,0 +1,339 @@ +// Package gobackend provides Storage and Credentials API for extension runtime +package gobackend + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/dop251/goja" +) + +// ==================== Storage API ==================== + +// getStoragePath returns the path to the extension's storage file +func (r *ExtensionRuntime) getStoragePath() string { + return filepath.Join(r.dataDir, "storage.json") +} + +// loadStorage loads the storage data from disk +func (r *ExtensionRuntime) loadStorage() (map[string]interface{}, error) { + storagePath := r.getStoragePath() + data, err := os.ReadFile(storagePath) + if err != nil { + if os.IsNotExist(err) { + return make(map[string]interface{}), nil + } + return nil, err + } + + var storage map[string]interface{} + if err := json.Unmarshal(data, &storage); err != nil { + return nil, err + } + + return storage, nil +} + +// saveStorage saves the storage data to disk +func (r *ExtensionRuntime) saveStorage(storage map[string]interface{}) error { + storagePath := r.getStoragePath() + data, err := json.MarshalIndent(storage, "", " ") + if err != nil { + return err + } + + return os.WriteFile(storagePath, data, 0644) +} + +// storageGet retrieves a value from storage +func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return goja.Undefined() + } + + key := call.Arguments[0].String() + + storage, err := r.loadStorage() + if err != nil { + GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err) + return goja.Undefined() + } + + value, exists := storage[key] + if !exists { + // Return default value if provided + if len(call.Arguments) > 1 { + return call.Arguments[1] + } + return goja.Undefined() + } + + return r.vm.ToValue(value) +} + +// storageSet stores a value in storage +func (r *ExtensionRuntime) storageSet(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 2 { + return r.vm.ToValue(false) + } + + key := call.Arguments[0].String() + value := call.Arguments[1].Export() + + storage, err := r.loadStorage() + if err != nil { + GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err) + return r.vm.ToValue(false) + } + + storage[key] = value + + if err := r.saveStorage(storage); err != nil { + GoLog("[Extension:%s] Storage save error: %v\n", r.extensionID, err) + return r.vm.ToValue(false) + } + + return r.vm.ToValue(true) +} + +// storageRemove removes a value from storage +func (r *ExtensionRuntime) storageRemove(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue(false) + } + + key := call.Arguments[0].String() + + storage, err := r.loadStorage() + if err != nil { + GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err) + return r.vm.ToValue(false) + } + + delete(storage, key) + + if err := r.saveStorage(storage); err != nil { + GoLog("[Extension:%s] Storage save error: %v\n", r.extensionID, err) + return r.vm.ToValue(false) + } + + return r.vm.ToValue(true) +} + +// ==================== Credentials API (Encrypted Storage) ==================== + +// getCredentialsPath returns the path to the extension's encrypted credentials file +func (r *ExtensionRuntime) getCredentialsPath() string { + return filepath.Join(r.dataDir, ".credentials.enc") +} + +// getEncryptionKey derives an encryption key from extension ID +func (r *ExtensionRuntime) getEncryptionKey() []byte { + // Use SHA256 of extension ID + salt as encryption key + salt := "spotiflac-ext-cred-v1" + hash := sha256.Sum256([]byte(r.extensionID + salt)) + return hash[:] +} + +// loadCredentials loads and decrypts credentials from disk +func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) { + credPath := r.getCredentialsPath() + data, err := os.ReadFile(credPath) + if err != nil { + if os.IsNotExist(err) { + return make(map[string]interface{}), nil + } + return nil, err + } + + // Decrypt the data + key := r.getEncryptionKey() + decrypted, err := decryptAES(data, key) + if err != nil { + return nil, fmt.Errorf("failed to decrypt credentials: %w", err) + } + + var creds map[string]interface{} + if err := json.Unmarshal(decrypted, &creds); err != nil { + return nil, err + } + + return creds, nil +} + +// saveCredentials encrypts and saves credentials to disk +func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error { + data, err := json.Marshal(creds) + if err != nil { + return err + } + + // Encrypt the data + key := r.getEncryptionKey() + encrypted, err := encryptAES(data, key) + if err != nil { + return fmt.Errorf("failed to encrypt credentials: %w", err) + } + + credPath := r.getCredentialsPath() + return os.WriteFile(credPath, encrypted, 0600) // Restrictive permissions +} + +// credentialsStore stores an encrypted credential +func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 2 { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "key and value are required", + }) + } + + key := call.Arguments[0].String() + value := call.Arguments[1].Export() + + creds, err := r.loadCredentials() + if err != nil { + GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err) + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + + creds[key] = value + + if err := r.saveCredentials(creds); err != nil { + GoLog("[Extension:%s] Credentials save error: %v\n", r.extensionID, err) + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + + return r.vm.ToValue(map[string]interface{}{ + "success": true, + }) +} + +// credentialsGet retrieves a decrypted credential +func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return goja.Undefined() + } + + key := call.Arguments[0].String() + + creds, err := r.loadCredentials() + if err != nil { + GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err) + return goja.Undefined() + } + + value, exists := creds[key] + if !exists { + // Return default value if provided + if len(call.Arguments) > 1 { + return call.Arguments[1] + } + return goja.Undefined() + } + + return r.vm.ToValue(value) +} + +// credentialsRemove removes a credential +func (r *ExtensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue(false) + } + + key := call.Arguments[0].String() + + creds, err := r.loadCredentials() + if err != nil { + GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err) + return r.vm.ToValue(false) + } + + delete(creds, key) + + if err := r.saveCredentials(creds); err != nil { + GoLog("[Extension:%s] Credentials save error: %v\n", r.extensionID, err) + return r.vm.ToValue(false) + } + + return r.vm.ToValue(true) +} + +// credentialsHas checks if a credential exists +func (r *ExtensionRuntime) credentialsHas(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue(false) + } + + key := call.Arguments[0].String() + + creds, err := r.loadCredentials() + if err != nil { + return r.vm.ToValue(false) + } + + _, exists := creds[key] + return r.vm.ToValue(exists) +} + +// ==================== Crypto Utilities ==================== + +// encryptAES encrypts data using AES-GCM +func encryptAES(plaintext []byte, key []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, err + } + + ciphertext := gcm.Seal(nonce, nonce, plaintext, nil) + return ciphertext, nil +} + +// decryptAES decrypts data using AES-GCM +func decryptAES(ciphertext []byte, key []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + nonceSize := gcm.NonceSize() + if len(ciphertext) < nonceSize { + return nil, fmt.Errorf("ciphertext too short") + } + + nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:] + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return nil, err + } + + return plaintext, nil +} diff --git a/go_backend/extension_runtime_utils.go b/go_backend/extension_runtime_utils.go new file mode 100644 index 0000000..b01693d --- /dev/null +++ b/go_backend/extension_runtime_utils.go @@ -0,0 +1,313 @@ +// Package gobackend provides Utility functions for extension runtime +package gobackend + +import ( + "crypto/hmac" + "crypto/md5" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + + "github.com/dop251/goja" +) + +// ==================== Utility Functions ==================== + +// base64Encode encodes a string to base64 +func (r *ExtensionRuntime) base64Encode(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue("") + } + input := call.Arguments[0].String() + return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input))) +} + +// base64Decode decodes a base64 string +func (r *ExtensionRuntime) base64Decode(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue("") + } + input := call.Arguments[0].String() + decoded, err := base64.StdEncoding.DecodeString(input) + if err != nil { + return r.vm.ToValue("") + } + return r.vm.ToValue(string(decoded)) +} + +// md5Hash computes MD5 hash of a string +func (r *ExtensionRuntime) md5Hash(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue("") + } + input := call.Arguments[0].String() + hash := md5.Sum([]byte(input)) + return r.vm.ToValue(hex.EncodeToString(hash[:])) +} + +// sha256Hash computes SHA256 hash of a string +func (r *ExtensionRuntime) sha256Hash(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue("") + } + input := call.Arguments[0].String() + hash := sha256.Sum256([]byte(input)) + return r.vm.ToValue(hex.EncodeToString(hash[:])) +} + +// hmacSHA256 computes HMAC-SHA256 of a message with a key +func (r *ExtensionRuntime) hmacSHA256(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 2 { + return r.vm.ToValue("") + } + message := call.Arguments[0].String() + key := call.Arguments[1].String() + + mac := hmac.New(sha256.New, []byte(key)) + mac.Write([]byte(message)) + return r.vm.ToValue(hex.EncodeToString(mac.Sum(nil))) +} + +// hmacSHA256Base64 computes HMAC-SHA256 and returns base64 encoded result +func (r *ExtensionRuntime) hmacSHA256Base64(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 2 { + return r.vm.ToValue("") + } + message := call.Arguments[0].String() + key := call.Arguments[1].String() + + mac := hmac.New(sha256.New, []byte(key)) + mac.Write([]byte(message)) + return r.vm.ToValue(base64.StdEncoding.EncodeToString(mac.Sum(nil))) +} + +// parseJSON parses a JSON string +func (r *ExtensionRuntime) parseJSON(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return goja.Undefined() + } + input := call.Arguments[0].String() + + var result interface{} + if err := json.Unmarshal([]byte(input), &result); err != nil { + GoLog("[Extension:%s] JSON parse error: %v\n", r.extensionID, err) + return goja.Undefined() + } + + return r.vm.ToValue(result) +} + +// stringifyJSON converts a value to JSON string +func (r *ExtensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue("") + } + input := call.Arguments[0].Export() + + data, err := json.Marshal(input) + if err != nil { + GoLog("[Extension:%s] JSON stringify error: %v\n", r.extensionID, err) + return r.vm.ToValue("") + } + + return r.vm.ToValue(string(data)) +} + +// ==================== Crypto Utilities for Extensions ==================== + +// cryptoEncrypt encrypts a string using AES-GCM (for extension use) +func (r *ExtensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 2 { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "plaintext and key are required", + }) + } + + plaintext := call.Arguments[0].String() + keyStr := call.Arguments[1].String() + + // Derive 32-byte key from provided key string + keyHash := sha256.Sum256([]byte(keyStr)) + + encrypted, err := encryptAES([]byte(plaintext), keyHash[:]) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + + return r.vm.ToValue(map[string]interface{}{ + "success": true, + "data": base64.StdEncoding.EncodeToString(encrypted), + }) +} + +// cryptoDecrypt decrypts a string using AES-GCM (for extension use) +func (r *ExtensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 2 { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "ciphertext and key are required", + }) + } + + ciphertextB64 := call.Arguments[0].String() + keyStr := call.Arguments[1].String() + + ciphertext, err := base64.StdEncoding.DecodeString(ciphertextB64) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "invalid base64 ciphertext", + }) + } + + // Derive 32-byte key from provided key string + keyHash := sha256.Sum256([]byte(keyStr)) + + decrypted, err := decryptAES(ciphertext, keyHash[:]) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + + return r.vm.ToValue(map[string]interface{}{ + "success": true, + "data": string(decrypted), + }) +} + +// cryptoGenerateKey generates a random encryption key +func (r *ExtensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value { + length := 32 // Default 256-bit key + if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) { + if l, ok := call.Arguments[0].Export().(float64); ok { + length = int(l) + } + } + + key := make([]byte, length) + if _, err := rand.Read(key); err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + + return r.vm.ToValue(map[string]interface{}{ + "success": true, + "key": base64.StdEncoding.EncodeToString(key), + "hex": hex.EncodeToString(key), + }) +} + +// ==================== Logging Functions ==================== + +func (r *ExtensionRuntime) logDebug(call goja.FunctionCall) goja.Value { + msg := r.formatLogArgs(call.Arguments) + GoLog("[Extension:%s:DEBUG] %s\n", r.extensionID, msg) + return goja.Undefined() +} + +func (r *ExtensionRuntime) logInfo(call goja.FunctionCall) goja.Value { + msg := r.formatLogArgs(call.Arguments) + GoLog("[Extension:%s:INFO] %s\n", r.extensionID, msg) + return goja.Undefined() +} + +func (r *ExtensionRuntime) logWarn(call goja.FunctionCall) goja.Value { + msg := r.formatLogArgs(call.Arguments) + GoLog("[Extension:%s:WARN] %s\n", r.extensionID, msg) + return goja.Undefined() +} + +func (r *ExtensionRuntime) logError(call goja.FunctionCall) goja.Value { + msg := r.formatLogArgs(call.Arguments) + GoLog("[Extension:%s:ERROR] %s\n", r.extensionID, msg) + return goja.Undefined() +} + +func (r *ExtensionRuntime) formatLogArgs(args []goja.Value) string { + parts := make([]string, len(args)) + for i, arg := range args { + parts[i] = fmt.Sprintf("%v", arg.Export()) + } + return strings.Join(parts, " ") +} + +// ==================== Go Backend Wrappers ==================== + +func (r *ExtensionRuntime) sanitizeFilenameWrapper(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue("") + } + input := call.Arguments[0].String() + return r.vm.ToValue(sanitizeFilename(input)) +} + +// RegisterGoBackendAPIs adds more Go backend functions to the VM +func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) { + gobackendObj := vm.Get("gobackend") + if gobackendObj == nil || goja.IsUndefined(gobackendObj) { + gobackendObj = vm.NewObject() + vm.Set("gobackend", gobackendObj) + } + + obj := gobackendObj.(*goja.Object) + + // Expose sanitizeFilename + obj.Set("sanitizeFilename", func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return vm.ToValue("") + } + return vm.ToValue(sanitizeFilename(call.Arguments[0].String())) + }) + + // Expose getAudioQuality + obj.Set("getAudioQuality", func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return vm.ToValue(map[string]interface{}{ + "error": "file path is required", + }) + } + + filePath := call.Arguments[0].String() + quality, err := GetAudioQuality(filePath) + if err != nil { + return vm.ToValue(map[string]interface{}{ + "error": err.Error(), + }) + } + + return vm.ToValue(map[string]interface{}{ + "bitDepth": quality.BitDepth, + "sampleRate": quality.SampleRate, + "totalSamples": quality.TotalSamples, + }) + }) + + // Expose buildFilename + obj.Set("buildFilename", func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 2 { + return vm.ToValue("") + } + + template := call.Arguments[0].String() + metadataObj := call.Arguments[1].Export() + + metadata, ok := metadataObj.(map[string]interface{}) + if !ok { + return vm.ToValue("") + } + + return vm.ToValue(buildFilenameFromTemplate(template, metadata)) + }) +}