mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-29 07:18:49 +02:00
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
303 lines
8.0 KiB
Go
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)
|
|
}
|