// Package gobackend provides extension runtime with sandboxed execution 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" "github.com/dop251/goja" ) // Global auth state for extensions (stores pending auth codes) var ( extensionAuthState = make(map[string]*ExtensionAuthState) extensionAuthStateMu sync.RWMutex ) // ExtensionAuthState holds auth state for an extension type ExtensionAuthState struct { PendingAuthURL string AuthCode string AccessToken string RefreshToken string ExpiresAt time.Time IsAuthenticated bool } // PendingAuthRequest holds a pending OAuth request that needs Flutter to open URL type PendingAuthRequest struct { ExtensionID string AuthURL string CallbackURL string } // Global pending auth requests (Flutter polls this) var ( pendingAuthRequests = make(map[string]*PendingAuthRequest) pendingAuthRequestsMu sync.RWMutex ) // GetPendingAuthRequest returns pending auth request for an extension (called from Flutter) func GetPendingAuthRequest(extensionID string) *PendingAuthRequest { pendingAuthRequestsMu.RLock() defer pendingAuthRequestsMu.RUnlock() return pendingAuthRequests[extensionID] } // ClearPendingAuthRequest clears pending auth request (called from Flutter after opening URL) func ClearPendingAuthRequest(extensionID string) { pendingAuthRequestsMu.Lock() defer pendingAuthRequestsMu.Unlock() delete(pendingAuthRequests, extensionID) } // SetExtensionAuthCode sets auth code for an extension (called from Flutter after OAuth callback) func SetExtensionAuthCode(extensionID string, authCode string) { extensionAuthStateMu.Lock() defer extensionAuthStateMu.Unlock() state, exists := extensionAuthState[extensionID] if !exists { state = &ExtensionAuthState{} extensionAuthState[extensionID] = state } state.AuthCode = authCode } // SetExtensionTokens sets access/refresh tokens for an extension func SetExtensionTokens(extensionID string, accessToken, refreshToken string, expiresAt time.Time) { extensionAuthStateMu.Lock() defer extensionAuthStateMu.Unlock() state, exists := extensionAuthState[extensionID] if !exists { state = &ExtensionAuthState{} extensionAuthState[extensionID] = state } state.AccessToken = accessToken state.RefreshToken = refreshToken state.ExpiresAt = expiresAt state.IsAuthenticated = accessToken != "" } // ExtensionRuntime provides sandboxed APIs for extensions type ExtensionRuntime struct { extensionID string manifest *ExtensionManifest settings map[string]interface{} httpClient *http.Client cookieJar http.CookieJar dataDir string vm *goja.Runtime } // NewExtensionRuntime creates a new runtime for an extension func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime { // Create a cookie jar for this extension jar, _ := newSimpleCookieJar() client := &http.Client{ Timeout: 30 * time.Second, Jar: jar, } return &ExtensionRuntime{ extensionID: ext.ID, manifest: ext.Manifest, settings: make(map[string]interface{}), httpClient: client, cookieJar: jar, dataDir: ext.DataDir, vm: ext.VM, } } // simpleCookieJar is a simple in-memory cookie jar type simpleCookieJar struct { cookies map[string][]*http.Cookie mu sync.RWMutex } func newSimpleCookieJar() (*simpleCookieJar, error) { return &simpleCookieJar{ cookies: make(map[string][]*http.Cookie), }, nil } func (j *simpleCookieJar) SetCookies(u *url.URL, cookies []*http.Cookie) { j.mu.Lock() defer j.mu.Unlock() key := u.Host j.cookies[key] = append(j.cookies[key], cookies...) } func (j *simpleCookieJar) Cookies(u *url.URL) []*http.Cookie { j.mu.RLock() defer j.mu.RUnlock() return j.cookies[u.Host] } // SetSettings updates the runtime settings func (r *ExtensionRuntime) SetSettings(settings map[string]interface{}) { r.settings = settings } // RegisterAPIs registers all sandboxed APIs to the Goja VM func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) { r.vm = vm // HTTP client (sandboxed to allowed domains) httpObj := vm.NewObject() httpObj.Set("get", r.httpGet) httpObj.Set("post", r.httpPost) httpObj.Set("put", r.httpPut) httpObj.Set("delete", r.httpDelete) httpObj.Set("patch", r.httpPatch) httpObj.Set("request", r.httpRequest) // Generic HTTP request (GET, POST, PUT, DELETE, etc.) httpObj.Set("clearCookies", r.httpClearCookies) vm.Set("http", httpObj) // Storage API storageObj := vm.NewObject() storageObj.Set("get", r.storageGet) storageObj.Set("set", r.storageSet) storageObj.Set("remove", r.storageRemove) vm.Set("storage", storageObj) // Secure Credentials API (encrypted storage for sensitive data) credentialsObj := vm.NewObject() credentialsObj.Set("store", r.credentialsStore) credentialsObj.Set("get", r.credentialsGet) credentialsObj.Set("remove", r.credentialsRemove) credentialsObj.Set("has", r.credentialsHas) vm.Set("credentials", credentialsObj) // Auth API (for OAuth and other auth flows) authObj := vm.NewObject() authObj.Set("openAuthUrl", r.authOpenUrl) authObj.Set("getAuthCode", r.authGetCode) authObj.Set("setAuthCode", r.authSetCode) authObj.Set("clearAuth", r.authClear) authObj.Set("isAuthenticated", r.authIsAuthenticated) authObj.Set("getTokens", r.authGetTokens) vm.Set("auth", authObj) // File operations (sandboxed) fileObj := vm.NewObject() fileObj.Set("download", r.fileDownload) fileObj.Set("exists", r.fileExists) fileObj.Set("delete", r.fileDelete) fileObj.Set("read", r.fileRead) fileObj.Set("write", r.fileWrite) fileObj.Set("copy", r.fileCopy) fileObj.Set("move", r.fileMove) fileObj.Set("getSize", r.fileGetSize) vm.Set("file", fileObj) // FFmpeg API (for post-processing) ffmpegObj := vm.NewObject() ffmpegObj.Set("execute", r.ffmpegExecute) ffmpegObj.Set("getInfo", r.ffmpegGetInfo) ffmpegObj.Set("convert", r.ffmpegConvert) vm.Set("ffmpeg", ffmpegObj) // Track matching API matchingObj := vm.NewObject() matchingObj.Set("compareStrings", r.matchingCompareStrings) matchingObj.Set("compareDuration", r.matchingCompareDuration) matchingObj.Set("normalizeString", r.matchingNormalizeString) vm.Set("matching", matchingObj) // Utilities utilsObj := vm.NewObject() utilsObj.Set("base64Encode", r.base64Encode) utilsObj.Set("base64Decode", r.base64Decode) utilsObj.Set("md5", r.md5Hash) utilsObj.Set("sha256", r.sha256Hash) utilsObj.Set("parseJSON", r.parseJSON) utilsObj.Set("stringifyJSON", r.stringifyJSON) // Crypto utilities for developers utilsObj.Set("encrypt", r.cryptoEncrypt) utilsObj.Set("decrypt", r.cryptoDecrypt) utilsObj.Set("generateKey", r.cryptoGenerateKey) vm.Set("utils", utilsObj) // Log object (already set in extension_manager.go, but we can enhance it) logObj := vm.NewObject() logObj.Set("debug", r.logDebug) logObj.Set("info", r.logInfo) logObj.Set("warn", r.logWarn) logObj.Set("error", r.logError) vm.Set("log", logObj) // Go backend functions gobackendObj := vm.NewObject() gobackendObj.Set("sanitizeFilename", r.sanitizeFilenameWrapper) vm.Set("gobackend", gobackendObj) // ==================== Browser-like Polyfills ==================== // These make porting browser/Node.js libraries easier // Global fetch() - Promise-style HTTP API (browser-compatible) vm.Set("fetch", r.fetchPolyfill) // Global atob/btoa - Base64 encoding (browser-compatible) vm.Set("atob", r.atobPolyfill) vm.Set("btoa", r.btoaPolyfill) // TextEncoder/TextDecoder constructors r.registerTextEncoderDecoder(vm) // URL class for URL parsing r.registerURLClass(vm) // 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) }