Files
SpotiFLAC-Mobile/go_backend/extension_runtime_http.go
T
zarzet 4e0cae9c20 refactor: extract shared helpers for repeated low-level patterns
Dart:
- notification_service: single _details() builder replaces 13 copies
  of the NotificationDetails block
- platform_bridge: _invokeMap() for 34 invoke+decode call sites,
  _cachedInvoke() unifies the three TTL/in-flight cache scaffolds
- ffmpeg_service: _promoteTempOutput(), _appendCoverInputArgs(),
  single _writeReplayGainTags() and _convertToLossless() for the
  ALAC/FLAC twins
- sqlite_helpers.dart: shared openAppDatabase/path-key/migration
  helpers for the three database classes
- library_collections: parametrized wishlist/loved/favorite CRUD
- extension_provider: one predicate-based replacedBuiltIn* lookup

Go:
- extension runtime: parseGojaHeaders/coerceGojaBody/doExtensionHTTP
  shared by httpGet/httpPost/httpRequest/shortcuts/fetch
- exports_metadata: applyAudioMetadataToResult + successMethodJSON,
  APE edit path reuses audioMetadataFromEditFields
- lyrics: lrclibGet() for both LRCLib fetchers
- extension_store: drop hand-rolled strings helpers
2026-07-12 14:10:23 +07:00

303 lines
8.0 KiB
Go

package gobackend
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/dop251/goja"
)
type HTTPResponse struct {
StatusCode int `json:"statusCode"`
Body string `json:"body"`
Headers map[string]string `json:"headers"`
}
const maxExtensionHTTPResponseBytes = 16 << 20
func readExtensionHTTPResponseBody(resp *http.Response) ([]byte, error) {
body, err := io.ReadAll(
io.LimitReader(resp.Body, maxExtensionHTTPResponseBytes+1),
)
if err != nil {
return nil, err
}
if len(body) > maxExtensionHTTPResponseBytes {
return nil, fmt.Errorf(
"response body exceeds %d byte limit; use file.download for large media",
maxExtensionHTTPResponseBytes,
)
}
return body, nil
}
func setDefaultExtensionUA(req *http.Request) {
if req.Header.Get("User-Agent") == "" {
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
}
}
func (r *extensionRuntime) validateDomain(urlStr string) error {
parsed, err := url.Parse(urlStr)
if err != nil {
return fmt.Errorf("invalid URL: %w", err)
}
if parsed.Scheme == "" {
return fmt.Errorf("invalid URL: scheme is required")
}
if parsed.Scheme != "https" &&
!(parsed.Scheme == "http" && r.manifest.Permissions.AllowHTTP) {
return fmt.Errorf("network access denied: only https is allowed")
}
if parsed.User != nil {
return fmt.Errorf("invalid URL: embedded credentials are not allowed")
}
domain := parsed.Hostname()
if domain == "" {
return fmt.Errorf("invalid URL: hostname is required")
}
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
}
// parseGojaHeaders converts an exported goja value (expected map[string]any)
// into string headers. Non-map values yield an empty map.
func parseGojaHeaders(v any) map[string]string {
headers := make(map[string]string)
if h, ok := v.(map[string]any); ok {
for k, val := range h {
headers[k] = fmt.Sprintf("%v", val)
}
}
return headers
}
// coerceExportedBody stringifies a request body already exported from goja:
// strings pass through, maps/arrays are JSON-encoded, anything else is %v.
func coerceExportedBody(v any) (string, error) {
switch b := v.(type) {
case string:
return b, nil
case map[string]any, []any:
jsonBytes, err := json.Marshal(b)
if err != nil {
return "", fmt.Errorf("failed to stringify body: %v", err)
}
return string(jsonBytes), nil
default:
return fmt.Sprintf("%v", b), nil
}
}
// coerceGojaBody is coerceExportedBody for a raw goja argument; undefined/null
// yield "", and the fallback uses goja's own String() conversion.
func coerceGojaBody(v goja.Value) (string, error) {
if v == nil || goja.IsUndefined(v) || goja.IsNull(v) {
return "", nil
}
switch b := v.Export().(type) {
case string:
return b, nil
case map[string]any, []any:
return coerceExportedBody(b)
default:
return v.String(), nil
}
}
func flattenHTTPHeaders(h http.Header) map[string]any {
flat := make(map[string]any, len(h))
for k, v := range h {
if len(v) == 1 {
flat[k] = v[0]
} else {
flat[k] = v
}
}
return flat
}
// checkExtensionURL extracts and allowlist-validates the URL argument.
// On failure it returns a non-nil error value to hand back to JS.
func (r *extensionRuntime) checkExtensionURL(call goja.FunctionCall) (string, goja.Value) {
if len(call.Arguments) < 1 {
return "", r.vm.ToValue(map[string]any{
"error": "URL is required",
})
}
urlStr := call.Arguments[0].String()
if err := r.validateDomain(urlStr); err != nil {
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
return "", r.vm.ToValue(map[string]any{
"error": err.Error(),
})
}
return urlStr, nil
}
// doExtensionHTTP builds and executes the request, returning the extension
// response map (or {"error": ...}). defaultJSON sets Content-Type
// application/json when the caller did not provide one.
func (r *extensionRuntime) doExtensionHTTP(method, urlStr string, body io.Reader, defaultJSON bool, headers map[string]string) goja.Value {
req, err := http.NewRequest(method, urlStr, body)
if err != nil {
return r.vm.ToValue(map[string]any{
"error": err.Error(),
})
}
req = r.bindDownloadCancelContext(req)
for k, v := range headers {
req.Header.Set(k, v)
}
setDefaultExtensionUA(req)
if defaultJSON && req.Header.Get("Content-Type") == "" {
req.Header.Set("Content-Type", "application/json")
}
resp, err := r.httpClient.Do(req)
if err != nil {
return r.vm.ToValue(map[string]any{
"error": err.Error(),
})
}
defer resp.Body.Close()
respBody, err := readExtensionHTTPResponseBody(resp)
if err != nil {
return r.vm.ToValue(map[string]any{
"error": err.Error(),
})
}
return r.vm.ToValue(map[string]any{
"statusCode": resp.StatusCode,
"status": resp.StatusCode,
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
"url": resp.Request.URL.String(),
"body": string(respBody),
"headers": flattenHTTPHeaders(resp.Header),
})
}
func (r *extensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
urlStr, errVal := r.checkExtensionURL(call)
if errVal != nil {
return errVal
}
headers := parseGojaHeaders(call.Argument(1).Export())
return r.doExtensionHTTP("GET", urlStr, nil, false, headers)
}
func (r *extensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
urlStr, errVal := r.checkExtensionURL(call)
if errVal != nil {
return errVal
}
bodyStr, err := coerceGojaBody(call.Argument(1))
if err != nil {
return r.vm.ToValue(map[string]any{
"error": err.Error(),
})
}
headers := parseGojaHeaders(call.Argument(2).Export())
// POST always sends a (possibly empty) body and defaults Content-Type.
return r.doExtensionHTTP("POST", urlStr, strings.NewReader(bodyStr), true, headers)
}
func (r *extensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
urlStr, errVal := r.checkExtensionURL(call)
if errVal != nil {
return errVal
}
method := "GET"
var bodyStr string
var headers map[string]string
if opts, ok := call.Argument(1).Export().(map[string]any); ok {
if m, ok := opts["method"].(string); ok {
method = strings.ToUpper(m)
}
if bodyArg, ok := opts["body"]; ok && bodyArg != nil {
var err error
if bodyStr, err = coerceExportedBody(bodyArg); err != nil {
return r.vm.ToValue(map[string]any{
"error": err.Error(),
})
}
}
headers = parseGojaHeaders(opts["headers"])
}
var reqBody io.Reader
if bodyStr != "" {
reqBody = strings.NewReader(bodyStr)
}
return r.doExtensionHTTP(method, urlStr, reqBody, bodyStr != "", headers)
}
func (r *extensionRuntime) httpPut(call goja.FunctionCall) goja.Value {
return r.httpMethodShortcut("PUT", call)
}
func (r *extensionRuntime) httpDelete(call goja.FunctionCall) goja.Value {
return r.httpMethodShortcut("DELETE", call)
}
func (r *extensionRuntime) httpPatch(call goja.FunctionCall) goja.Value {
return r.httpMethodShortcut("PATCH", call)
}
func (r *extensionRuntime) httpMethodShortcut(method string, call goja.FunctionCall) goja.Value {
urlStr, errVal := r.checkExtensionURL(call)
if errVal != nil {
return errVal
}
// DELETE takes (url, headers); other methods take (url, body, headers).
var bodyStr string
headerArg := 1
if method != "DELETE" {
var err error
if bodyStr, err = coerceGojaBody(call.Argument(1)); err != nil {
return r.vm.ToValue(map[string]any{
"error": err.Error(),
})
}
headerArg = 2
}
headers := parseGojaHeaders(call.Argument(headerArg).Export())
var reqBody io.Reader
if bodyStr != "" {
reqBody = strings.NewReader(bodyStr)
}
return r.doExtensionHTTP(method, urlStr, reqBody, bodyStr != "", headers)
}
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)
}