mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-30 11:29:30 +02:00
8a7815268b
- Add file permission requirement for extensions - Bump version to 3.0.0-beta.1
506 lines
14 KiB
Go
506 lines
14 KiB
Go
// 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()
|
|
|
|
// Block private/local network access (SSRF protection)
|
|
if isPrivateIP(domain) {
|
|
return fmt.Errorf("network access denied: private/local network '%s' not allowed", domain)
|
|
}
|
|
|
|
if !r.manifest.IsDomainAllowed(domain) {
|
|
return fmt.Errorf("network access denied: domain '%s' not in allowed list", domain)
|
|
}
|
|
|
|
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)
|
|
}
|