mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-17 08:30:33 +02:00
feat: extension runtime and provider improvements
This commit is contained in:
@@ -3263,6 +3263,10 @@ func SetExtensionAuthCodeByID(extensionID, authCode string) {
|
||||
SetExtensionAuthCode(extensionID, authCode)
|
||||
}
|
||||
|
||||
func SetExtensionSessionGrantByID(extensionID, grant string) {
|
||||
setPendingSignedSessionGrant(extensionID, grant)
|
||||
}
|
||||
|
||||
func SetExtensionTokensByID(extensionID, accessToken, refreshToken string, expiresIn int) {
|
||||
var expiresAt time.Time
|
||||
if expiresIn > 0 {
|
||||
@@ -3935,9 +3939,12 @@ func callExtensionFunctionJSONWithRequestID(extensionID, functionName string, ti
|
||||
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
|
||||
return extension.%s();
|
||||
}
|
||||
if (typeof %s === 'function') {
|
||||
return %s();
|
||||
}
|
||||
return null;
|
||||
})()
|
||||
`, functionName, functionName)
|
||||
`, functionName, functionName, functionName, functionName)
|
||||
|
||||
jsStartedAt := time.Now()
|
||||
result, err := RunWithTimeoutContextAndRecover(requestCtx, vm, script, timeout)
|
||||
|
||||
@@ -44,6 +44,11 @@ func compareVersions(v1, v2 string) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
func isExtensionPackagePath(filePath string) bool {
|
||||
lowerPath := strings.ToLower(filePath)
|
||||
return strings.HasSuffix(lowerPath, ".spotiflac-ext") || strings.HasSuffix(lowerPath, ".sflx")
|
||||
}
|
||||
|
||||
type loadedExtension struct {
|
||||
ID string `json:"id"`
|
||||
Manifest *ExtensionManifest `json:"manifest"`
|
||||
@@ -166,8 +171,8 @@ func (m *extensionManager) LoadExtensionFromFile(filePath string) (*loadedExtens
|
||||
}
|
||||
|
||||
func (m *extensionManager) loadExtensionFromFileLocked(filePath string) (*loadedExtension, error) {
|
||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
||||
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext file")
|
||||
if !isExtensionPackagePath(filePath) {
|
||||
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext or .sflx file")
|
||||
}
|
||||
|
||||
zipReader, err := zip.OpenReader(filePath)
|
||||
@@ -673,7 +678,7 @@ func (m *extensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string
|
||||
loaded = append(loaded, ext.ID)
|
||||
}
|
||||
}
|
||||
} else if strings.HasSuffix(strings.ToLower(entry.Name()), ".spotiflac-ext") {
|
||||
} else if isExtensionPackagePath(entry.Name()) {
|
||||
ext, err := m.LoadExtensionFromFile(filepath.Join(dirPath, entry.Name()))
|
||||
if err != nil {
|
||||
GoLog("[Extension] Failed to load %s: %v\n", entry.Name(), err)
|
||||
@@ -775,8 +780,8 @@ func (m *extensionManager) UpgradeExtension(filePath string) (*loadedExtension,
|
||||
}
|
||||
|
||||
func (m *extensionManager) upgradeExtensionLocked(filePath string) (*loadedExtension, error) {
|
||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
||||
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext file")
|
||||
if !isExtensionPackagePath(filePath) {
|
||||
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext or .sflx file")
|
||||
}
|
||||
|
||||
zipReader, err := zip.OpenReader(filePath)
|
||||
@@ -924,8 +929,8 @@ type ExtensionUpgradeInfo struct {
|
||||
}
|
||||
|
||||
func (m *extensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) {
|
||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
||||
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext file")
|
||||
if !isExtensionPackagePath(filePath) {
|
||||
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext or .sflx file")
|
||||
}
|
||||
|
||||
zipReader, err := zip.OpenReader(filePath)
|
||||
|
||||
@@ -3,6 +3,7 @@ package gobackend
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -113,28 +114,49 @@ type ExtensionHealthCheck struct {
|
||||
Required bool `json:"required,omitempty"`
|
||||
}
|
||||
|
||||
type SignedSessionEndpoints struct {
|
||||
Bootstrap string `json:"bootstrap,omitempty"`
|
||||
Challenge string `json:"challenge,omitempty"`
|
||||
Exchange string `json:"exchange,omitempty"`
|
||||
Refresh string `json:"refresh,omitempty"`
|
||||
}
|
||||
|
||||
type SignedSessionConfig struct {
|
||||
Namespace string `json:"namespace"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
AppVersion string `json:"appVersion,omitempty"`
|
||||
Platform string `json:"platform,omitempty"`
|
||||
CallbackURL string `json:"callbackUrl,omitempty"`
|
||||
SchemeLabel string `json:"schemeLabel,omitempty"`
|
||||
HeaderPrefix string `json:"headerPrefix,omitempty"`
|
||||
TimeWindowSeconds int `json:"timeWindowSeconds,omitempty"`
|
||||
Endpoints SignedSessionEndpoints `json:"endpoints,omitempty"`
|
||||
}
|
||||
|
||||
type ExtensionManifest struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description"`
|
||||
Homepage string `json:"homepage,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Types []ExtensionType `json:"type"`
|
||||
Permissions ExtensionPermissions `json:"permissions"`
|
||||
Settings []ExtensionSetting `json:"settings,omitempty"`
|
||||
QualityOptions []QualityOption `json:"qualityOptions,omitempty"`
|
||||
MinAppVersion string `json:"minAppVersion,omitempty"`
|
||||
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"`
|
||||
SkipLyrics bool `json:"skipLyrics,omitempty"`
|
||||
StopProviderFallback bool `json:"stopProviderFallback,omitempty"`
|
||||
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"`
|
||||
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"`
|
||||
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"`
|
||||
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"`
|
||||
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"`
|
||||
ServiceHealth []ExtensionHealthCheck `json:"serviceHealth,omitempty"`
|
||||
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description"`
|
||||
Homepage string `json:"homepage,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Types []ExtensionType `json:"type"`
|
||||
Permissions ExtensionPermissions `json:"permissions"`
|
||||
Settings []ExtensionSetting `json:"settings,omitempty"`
|
||||
QualityOptions []QualityOption `json:"qualityOptions,omitempty"`
|
||||
MinAppVersion string `json:"minAppVersion,omitempty"`
|
||||
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"`
|
||||
SkipLyrics bool `json:"skipLyrics,omitempty"`
|
||||
StopProviderFallback bool `json:"stopProviderFallback,omitempty"`
|
||||
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"`
|
||||
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"`
|
||||
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"`
|
||||
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"`
|
||||
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"`
|
||||
ServiceHealth []ExtensionHealthCheck `json:"serviceHealth,omitempty"`
|
||||
SignedSession *SignedSessionConfig `json:"signedSession,omitempty"`
|
||||
RequiredRuntimeFeatures []string `json:"requiredRuntimeFeatures,omitempty"`
|
||||
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
|
||||
}
|
||||
|
||||
type ManifestValidationError struct {
|
||||
@@ -238,6 +260,26 @@ func (m *ExtensionManifest) Validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
if m.SignedSession != nil {
|
||||
if strings.TrimSpace(m.SignedSession.Namespace) == "" {
|
||||
return &ManifestValidationError{Field: "signedSession.namespace", Message: "namespace is required"}
|
||||
}
|
||||
baseURL := strings.TrimSpace(m.SignedSession.BaseURL)
|
||||
if baseURL == "" {
|
||||
return &ManifestValidationError{Field: "signedSession.baseUrl", Message: "baseUrl is required"}
|
||||
}
|
||||
if !strings.HasPrefix(strings.ToLower(baseURL), "https://") {
|
||||
return &ManifestValidationError{Field: "signedSession.baseUrl", Message: "baseUrl must use https"}
|
||||
}
|
||||
parsed, err := url.Parse(baseURL)
|
||||
if err != nil || parsed.Hostname() == "" {
|
||||
return &ManifestValidationError{Field: "signedSession.baseUrl", Message: "baseUrl is invalid"}
|
||||
}
|
||||
if !m.IsDomainAllowed(parsed.Hostname()) {
|
||||
return &ManifestValidationError{Field: "signedSession.baseUrl", Message: "baseUrl host must be listed in permissions.network"}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -2135,6 +2135,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
var lastErrType string
|
||||
var stopProviderFallback bool
|
||||
var sourceExtensionLocked bool
|
||||
var sourceExtensionAvailability *ExtAvailabilityResult
|
||||
@@ -2449,11 +2450,23 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
}, nil
|
||||
}
|
||||
lastErr = err
|
||||
lastErrType = ""
|
||||
} else if result.ErrorMessage != "" {
|
||||
lastErr = fmt.Errorf("%s", result.ErrorMessage)
|
||||
lastErrType = strings.TrimSpace(result.ErrorType)
|
||||
}
|
||||
GoLog("[DownloadWithExtensionFallback] Source extension %s failed: %v\n", req.Source, lastErr)
|
||||
|
||||
if strings.EqualFold(lastErrType, "verification_required") {
|
||||
GoLog("[DownloadWithExtensionFallback] Source extension %s requires verification, not trying other providers\n", req.Source)
|
||||
return &DownloadResponse{
|
||||
Success: false,
|
||||
Error: "Download failed: " + lastErr.Error(),
|
||||
ErrorType: "verification_required",
|
||||
Service: req.Source,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if stopProviderFallback || sourceExtensionLocked {
|
||||
if sourceExtensionLocked {
|
||||
GoLog("[DownloadWithExtensionFallback] Source extension %s requested skip_fallback, not trying other providers\n", req.Source)
|
||||
@@ -2463,7 +2476,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
return &DownloadResponse{
|
||||
Success: false,
|
||||
Error: "Download failed: " + lastErr.Error(),
|
||||
ErrorType: "extension_error",
|
||||
ErrorType: firstNonEmptyString(lastErrType, "extension_error"),
|
||||
Service: req.Source,
|
||||
}, nil
|
||||
}
|
||||
@@ -2632,8 +2645,10 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
}, nil
|
||||
}
|
||||
lastErr = err
|
||||
lastErrType = ""
|
||||
} else if result.ErrorMessage != "" {
|
||||
lastErr = fmt.Errorf("%s", result.ErrorMessage)
|
||||
lastErrType = strings.TrimSpace(result.ErrorType)
|
||||
}
|
||||
GoLog("[DownloadWithExtensionFallback] %s failed: %v\n", providerID, lastErr)
|
||||
if terminalAvailability {
|
||||
@@ -2644,7 +2659,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
}
|
||||
|
||||
if lastErr != nil {
|
||||
errorType := classifyDownloadErrorType(lastErr.Error())
|
||||
errorType := firstNonEmptyString(lastErrType, classifyDownloadErrorType(lastErr.Error()))
|
||||
if errorType == "unknown" {
|
||||
errorType = "not_found"
|
||||
}
|
||||
|
||||
@@ -465,6 +465,15 @@ func (r *extensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
authObj.Set("exchangeCodeWithPKCE", r.authExchangeCodeWithPKCE)
|
||||
vm.Set("auth", authObj)
|
||||
|
||||
if r.manifest != nil && r.manifest.SignedSession != nil {
|
||||
sessionObj := vm.NewObject()
|
||||
sessionObj.Set("signedFetch", r.signedSessionFetch)
|
||||
sessionObj.Set("completeGrant", r.signedSessionCompleteGrant)
|
||||
sessionObj.Set("status", r.signedSessionStatus)
|
||||
sessionObj.Set("clear", r.signedSessionClear)
|
||||
vm.Set("session", sessionObj)
|
||||
}
|
||||
|
||||
fileObj := vm.NewObject()
|
||||
fileObj.Set("download", r.fileDownload)
|
||||
fileObj.Set("exists", r.fileExists)
|
||||
|
||||
@@ -0,0 +1,595 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/hmac"
|
||||
"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"
|
||||
)
|
||||
|
||||
const signedSessionRefreshSkew = time.Hour
|
||||
|
||||
var (
|
||||
pendingSignedSessionGrants = make(map[string]string)
|
||||
pendingSignedSessionGrantsMu sync.Mutex
|
||||
)
|
||||
|
||||
type signedSessionRecord struct {
|
||||
InstallID string `json:"install_id"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
SessionSecret string `json:"session_secret,omitempty"`
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
type signedSessionExchangeResponse struct {
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
SessionSecret string `json:"session_secret,omitempty"`
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
ChallengeID string `json:"challenge_id,omitempty"`
|
||||
ChallengeURL string `json:"challenge_url,omitempty"`
|
||||
AuthURL string `json:"auth_url,omitempty"`
|
||||
}
|
||||
|
||||
func signedSessionConfigWithDefaults(config *SignedSessionConfig) SignedSessionConfig {
|
||||
if config == nil {
|
||||
return SignedSessionConfig{}
|
||||
}
|
||||
resolved := *config
|
||||
if resolved.AppVersion == "" {
|
||||
resolved.AppVersion = "ext-1.0"
|
||||
}
|
||||
if resolved.Platform == "" {
|
||||
resolved.Platform = "extension"
|
||||
}
|
||||
if resolved.CallbackURL == "" {
|
||||
resolved.CallbackURL = "spotiflac://session-grant"
|
||||
}
|
||||
if resolved.SchemeLabel == "" {
|
||||
resolved.SchemeLabel = "SPOTIFLAC-HMAC-V1"
|
||||
}
|
||||
if resolved.HeaderPrefix == "" {
|
||||
resolved.HeaderPrefix = "X-Sig-"
|
||||
}
|
||||
if resolved.TimeWindowSeconds <= 0 {
|
||||
resolved.TimeWindowSeconds = 300
|
||||
}
|
||||
if resolved.Endpoints.Bootstrap == "" {
|
||||
resolved.Endpoints.Bootstrap = "/bootstrap"
|
||||
}
|
||||
if resolved.Endpoints.Challenge == "" {
|
||||
resolved.Endpoints.Challenge = "/challenge"
|
||||
}
|
||||
if resolved.Endpoints.Exchange == "" {
|
||||
resolved.Endpoints.Exchange = "/session/exchange"
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) signedSessionFilePath(config SignedSessionConfig) (string, error) {
|
||||
namespace := sanitizeSignedSessionNamespace(config.Namespace)
|
||||
if namespace == "" {
|
||||
return "", fmt.Errorf("signed session namespace is empty")
|
||||
}
|
||||
baseDir := filepath.Dir(r.dataDir)
|
||||
if baseDir == "." || baseDir == "" {
|
||||
baseDir = r.dataDir
|
||||
}
|
||||
dir := filepath.Join(baseDir, "signed_sessions")
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(dir, namespace+".json"), nil
|
||||
}
|
||||
|
||||
func sanitizeSignedSessionNamespace(namespace string) string {
|
||||
namespace = strings.TrimSpace(strings.ToLower(namespace))
|
||||
var b strings.Builder
|
||||
for _, ch := range namespace {
|
||||
if (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '_' || ch == '.' {
|
||||
b.WriteRune(ch)
|
||||
}
|
||||
}
|
||||
return strings.Trim(b.String(), ".-_")
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) loadSignedSession(config SignedSessionConfig) (*signedSessionRecord, error) {
|
||||
path, err := r.signedSessionFilePath(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
record := &signedSessionRecord{}
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
_ = json.Unmarshal(data, record)
|
||||
}
|
||||
if strings.TrimSpace(record.InstallID) == "" {
|
||||
record.InstallID = randomHex(16)
|
||||
if err := r.saveSignedSession(config, record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) saveSignedSession(config SignedSessionConfig, record *signedSessionRecord) error {
|
||||
path, err := r.signedSessionFilePath(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(record, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0600)
|
||||
}
|
||||
|
||||
func randomHex(bytesLen int) string {
|
||||
buf := make([]byte, bytesLen)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
}
|
||||
return hex.EncodeToString(buf)
|
||||
}
|
||||
|
||||
func parseSignedSessionTime(value string) (time.Time, bool) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
layouts := []string{
|
||||
time.RFC3339Nano,
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04:05.000Z",
|
||||
}
|
||||
for _, layout := range layouts {
|
||||
if parsed, err := time.Parse(layout, value); err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) signedSessionStatus(call goja.FunctionCall) goja.Value {
|
||||
config := signedSessionConfigWithDefaults(r.manifest.SignedSession)
|
||||
if config.Namespace == "" || config.BaseURL == "" {
|
||||
return r.vm.ToValue(map[string]interface{}{"authenticated": false, "error": "signedSession is not configured"})
|
||||
}
|
||||
record, err := r.loadSignedSession(config)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{"authenticated": false, "error": err.Error()})
|
||||
}
|
||||
authenticated := record.SessionID != "" && record.SessionSecret != ""
|
||||
if expiresAt, ok := parseSignedSessionTime(record.ExpiresAt); ok && time.Now().After(expiresAt) {
|
||||
authenticated = false
|
||||
}
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"authenticated": authenticated,
|
||||
"expires_at": record.ExpiresAt,
|
||||
"install_id": record.InstallID,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) signedSessionClear(call goja.FunctionCall) goja.Value {
|
||||
config := signedSessionConfigWithDefaults(r.manifest.SignedSession)
|
||||
record, err := r.loadSignedSession(config)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{"success": false, "error": err.Error()})
|
||||
}
|
||||
record.SessionID = ""
|
||||
record.SessionSecret = ""
|
||||
record.ExpiresAt = ""
|
||||
if err := r.saveSignedSession(config, record); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{"success": false, "error": err.Error()})
|
||||
}
|
||||
return r.vm.ToValue(map[string]interface{}{"success": true})
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) signedSessionCompleteGrant(call goja.FunctionCall) goja.Value {
|
||||
grant := ""
|
||||
if len(call.Arguments) > 0 {
|
||||
grant = strings.TrimSpace(call.Arguments[0].String())
|
||||
}
|
||||
if grant == "" {
|
||||
pendingSignedSessionGrantsMu.Lock()
|
||||
grant = pendingSignedSessionGrants[r.extensionID]
|
||||
delete(pendingSignedSessionGrants, r.extensionID)
|
||||
pendingSignedSessionGrantsMu.Unlock()
|
||||
}
|
||||
if grant == "" {
|
||||
return r.vm.ToValue(map[string]interface{}{"success": false, "error": "no pending grant"})
|
||||
}
|
||||
if err := r.exchangeSignedSessionGrant(grant); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{"success": false, "error": err.Error()})
|
||||
}
|
||||
return r.vm.ToValue(map[string]interface{}{"success": true})
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) exchangeSignedSessionGrant(grant string) error {
|
||||
config := signedSessionConfigWithDefaults(r.manifest.SignedSession)
|
||||
record, err := r.loadSignedSession(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
endpoint, err := signedSessionURL(config, config.Endpoints.Exchange)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"grant": grant,
|
||||
"install_id": record.InstallID,
|
||||
"app_version": config.AppVersion,
|
||||
"platform": config.Platform,
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", "SpotiFLAC-Mobile/"+config.AppVersion)
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, err := readExtensionHTTPResponseBody(resp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("session exchange failed: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
var exchanged signedSessionExchangeResponse
|
||||
if err := json.Unmarshal(respBody, &exchanged); err != nil {
|
||||
return fmt.Errorf("invalid session exchange response: %w", err)
|
||||
}
|
||||
if exchanged.SessionID == "" || exchanged.SessionSecret == "" || exchanged.ExpiresAt == "" {
|
||||
return fmt.Errorf("session exchange response missing session fields")
|
||||
}
|
||||
record.SessionID = exchanged.SessionID
|
||||
record.SessionSecret = exchanged.SessionSecret
|
||||
record.ExpiresAt = exchanged.ExpiresAt
|
||||
return r.saveSignedSession(config, record)
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) signedSessionFetch(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{"ok": false, "error": "method and path are required"})
|
||||
}
|
||||
config := signedSessionConfigWithDefaults(r.manifest.SignedSession)
|
||||
if config.Namespace == "" || config.BaseURL == "" {
|
||||
return r.vm.ToValue(map[string]interface{}{"ok": false, "error": "signedSession is not configured"})
|
||||
}
|
||||
method := strings.ToUpper(strings.TrimSpace(call.Arguments[0].String()))
|
||||
requestPath := call.Arguments[1].String()
|
||||
body := []byte{}
|
||||
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
|
||||
switch v := call.Arguments[2].Export().(type) {
|
||||
case string:
|
||||
body = []byte(v)
|
||||
case map[string]interface{}, []interface{}:
|
||||
encoded, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{"ok": false, "error": err.Error()})
|
||||
}
|
||||
body = encoded
|
||||
default:
|
||||
body = []byte(call.Arguments[2].String())
|
||||
}
|
||||
}
|
||||
extraHeaders := map[string]string{}
|
||||
if len(call.Arguments) > 3 && !goja.IsUndefined(call.Arguments[3]) && !goja.IsNull(call.Arguments[3]) {
|
||||
if h, ok := call.Arguments[3].Export().(map[string]interface{}); ok {
|
||||
for k, v := range h {
|
||||
extraHeaders[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
record, err := r.ensureSignedSession(config)
|
||||
if err != nil {
|
||||
if authURL := r.startSignedSessionVerification(config, ""); authURL != "" {
|
||||
return r.signedSessionVerificationRequiredValue(authURL)
|
||||
}
|
||||
return r.vm.ToValue(map[string]interface{}{"ok": false, "error": err.Error()})
|
||||
}
|
||||
|
||||
resp, respBody, respHeaders, err := r.doSignedSessionRequest(config, record, method, requestPath, body, extraHeaders)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{"ok": false, "error": err.Error()})
|
||||
}
|
||||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusPreconditionRequired {
|
||||
record.SessionID = ""
|
||||
record.SessionSecret = ""
|
||||
record.ExpiresAt = ""
|
||||
_ = r.saveSignedSession(config, record)
|
||||
if authURL := r.startSignedSessionVerification(config, ""); authURL != "" {
|
||||
return r.signedSessionVerificationRequiredValue(authURL)
|
||||
}
|
||||
}
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"statusCode": resp.StatusCode,
|
||||
"status": resp.StatusCode,
|
||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||
"url": resp.Request.URL.String(),
|
||||
"body": string(respBody),
|
||||
"headers": respHeaders,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) signedSessionVerificationRequiredValue(authURL string) goja.Value {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"ok": false,
|
||||
"needsVerification": true,
|
||||
"error": "VERIFY_REQUIRED",
|
||||
"open_auth_url": authURL,
|
||||
"auth_url": authURL,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) ensureSignedSession(config SignedSessionConfig) (*signedSessionRecord, error) {
|
||||
record, err := r.loadSignedSession(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if record.SessionID == "" || record.SessionSecret == "" {
|
||||
return nil, fmt.Errorf("signed session is not authenticated")
|
||||
}
|
||||
if expiresAt, ok := parseSignedSessionTime(record.ExpiresAt); ok {
|
||||
if time.Now().After(expiresAt) {
|
||||
record.SessionID = ""
|
||||
record.SessionSecret = ""
|
||||
record.ExpiresAt = ""
|
||||
_ = r.saveSignedSession(config, record)
|
||||
return nil, fmt.Errorf("signed session expired")
|
||||
}
|
||||
if config.Endpoints.Refresh != "" && time.Until(expiresAt) <= signedSessionRefreshSkew {
|
||||
_ = r.refreshSignedSession(config, record)
|
||||
}
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) refreshSignedSession(config SignedSessionConfig, record *signedSessionRecord) error {
|
||||
body, _ := json.Marshal(map[string]string{"install_id": record.InstallID})
|
||||
resp, respBody, _, err := r.doSignedSessionRequest(config, record, http.MethodPost, config.Endpoints.Refresh, body, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("session refresh failed: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
var refreshed signedSessionExchangeResponse
|
||||
if err := json.Unmarshal(respBody, &refreshed); err != nil {
|
||||
return err
|
||||
}
|
||||
changed := false
|
||||
if refreshed.SessionID != "" {
|
||||
record.SessionID = refreshed.SessionID
|
||||
changed = true
|
||||
}
|
||||
if refreshed.SessionSecret != "" {
|
||||
record.SessionSecret = refreshed.SessionSecret
|
||||
changed = true
|
||||
}
|
||||
if refreshed.ExpiresAt != "" && refreshed.ExpiresAt != record.ExpiresAt {
|
||||
record.ExpiresAt = refreshed.ExpiresAt
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
return r.saveSignedSession(config, record)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) startSignedSessionVerification(config SignedSessionConfig, reason string) string {
|
||||
record, err := r.loadSignedSession(config)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
bootstrapURL, err := signedSessionURL(config, config.Endpoints.Bootstrap)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
parsed, _ := url.Parse(bootstrapURL)
|
||||
query := parsed.Query()
|
||||
query.Set("app_version", config.AppVersion)
|
||||
query.Set("install_id", record.InstallID)
|
||||
parsed.RawQuery = query.Encode()
|
||||
req, err := http.NewRequest(http.MethodGet, parsed.String(), nil)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", "SpotiFLAC-Mobile/"+config.AppVersion)
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxExtensionHTTPResponseBytes))
|
||||
if err != nil || resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return ""
|
||||
}
|
||||
var boot signedSessionExchangeResponse
|
||||
if err := json.Unmarshal(body, &boot); err != nil {
|
||||
return ""
|
||||
}
|
||||
if boot.SessionID != "" && boot.SessionSecret != "" && boot.ExpiresAt != "" {
|
||||
record.SessionID = boot.SessionID
|
||||
record.SessionSecret = boot.SessionSecret
|
||||
record.ExpiresAt = boot.ExpiresAt
|
||||
_ = r.saveSignedSession(config, record)
|
||||
return ""
|
||||
}
|
||||
authURL := boot.AuthURL
|
||||
if authURL == "" && boot.ChallengeURL != "" {
|
||||
authURL = boot.ChallengeURL
|
||||
}
|
||||
if authURL == "" && boot.ChallengeID != "" {
|
||||
authURL = r.buildSignedSessionChallengeURL(config, boot.ChallengeID)
|
||||
}
|
||||
if authURL != "" {
|
||||
pendingAuthRequestsMu.Lock()
|
||||
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
|
||||
ExtensionID: r.extensionID,
|
||||
AuthURL: authURL,
|
||||
CallbackURL: config.CallbackURL,
|
||||
}
|
||||
pendingAuthRequestsMu.Unlock()
|
||||
}
|
||||
return authURL
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) buildSignedSessionChallengeURL(config SignedSessionConfig, challengeID string) string {
|
||||
challengeURL, err := signedSessionURL(config, config.Endpoints.Challenge)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
parsed, err := url.Parse(challengeURL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
callback, err := url.Parse(config.CallbackURL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
q := callback.Query()
|
||||
q.Set("cb_version", "v2grant")
|
||||
q.Set("state", r.extensionID)
|
||||
callback.RawQuery = q.Encode()
|
||||
|
||||
query := parsed.Query()
|
||||
query.Set("id", challengeID)
|
||||
query.Set("cb", callback.String())
|
||||
parsed.RawQuery = query.Encode()
|
||||
return parsed.String()
|
||||
}
|
||||
|
||||
func signedSessionURL(config SignedSessionConfig, endpoint string) (string, error) {
|
||||
base, err := url.Parse(strings.TrimRight(config.BaseURL, "/") + "/")
|
||||
if err != nil || base.Scheme != "https" || base.Host == "" {
|
||||
return "", fmt.Errorf("invalid signed session baseUrl")
|
||||
}
|
||||
endpoint = strings.TrimSpace(endpoint)
|
||||
if endpoint == "" {
|
||||
return "", fmt.Errorf("signed session endpoint is empty")
|
||||
}
|
||||
if strings.HasPrefix(endpoint, "https://") {
|
||||
return endpoint, nil
|
||||
}
|
||||
endpoint = strings.TrimLeft(endpoint, "/")
|
||||
ref, _ := url.Parse(endpoint)
|
||||
return base.ResolveReference(ref).String(), nil
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) doSignedSessionRequest(
|
||||
config SignedSessionConfig,
|
||||
record *signedSessionRecord,
|
||||
method string,
|
||||
requestPath string,
|
||||
body []byte,
|
||||
extraHeaders map[string]string,
|
||||
) (*http.Response, []byte, map[string]interface{}, error) {
|
||||
fullURL, err := signedSessionURL(config, requestPath)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
parsed, err := url.Parse(fullURL)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
ts := time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
|
||||
nonce := randomHex(12)
|
||||
bodyHashBytes := sha256.Sum256(body)
|
||||
bodyHash := hex.EncodeToString(bodyHashBytes[:])
|
||||
parsedTs, _ := time.Parse("2006-01-02T15:04:05.000Z", ts)
|
||||
window := parsedTs.Unix() / int64(config.TimeWindowSeconds)
|
||||
rollingInput := fmt.Sprintf("%d:%s", window, record.SessionID)
|
||||
rk := base64.RawURLEncoding.EncodeToString(hmacSHA256Bytes([]byte(record.SessionSecret), []byte(rollingInput)))
|
||||
signingInput := strings.Join([]string{
|
||||
config.SchemeLabel,
|
||||
method,
|
||||
parsed.EscapedPath(),
|
||||
"",
|
||||
bodyHash,
|
||||
ts,
|
||||
nonce,
|
||||
record.SessionID,
|
||||
config.AppVersion,
|
||||
config.Platform,
|
||||
}, "\n")
|
||||
sig := base64.RawURLEncoding.EncodeToString(hmacSHA256Bytes([]byte(rk), []byte(signingInput)))
|
||||
|
||||
req, err := http.NewRequest(method, fullURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
req = r.bindDownloadCancelContext(req)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if len(body) > 0 {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
req.Header.Set("User-Agent", "SpotiFLAC-Mobile/"+config.AppVersion)
|
||||
prefix := config.HeaderPrefix
|
||||
req.Header.Set(prefix+"Session", record.SessionID)
|
||||
req.Header.Set(prefix+"Timestamp", ts)
|
||||
req.Header.Set(prefix+"Nonce", nonce)
|
||||
req.Header.Set(prefix+"Body-SHA256", bodyHash)
|
||||
req.Header.Set(prefix+"Signature", sig)
|
||||
req.Header.Set(prefix+"App-Version", config.AppVersion)
|
||||
req.Header.Set(prefix+"Platform", config.Platform)
|
||||
for k, v := range extraHeaders {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, err := readExtensionHTTPResponseBody(resp)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
headers := make(map[string]interface{})
|
||||
for k, v := range resp.Header {
|
||||
if len(v) == 1 {
|
||||
headers[k] = v[0]
|
||||
} else {
|
||||
headers[k] = v
|
||||
}
|
||||
}
|
||||
return resp, respBody, headers, nil
|
||||
}
|
||||
|
||||
func hmacSHA256Bytes(key, message []byte) []byte {
|
||||
mac := hmac.New(sha256.New, key)
|
||||
mac.Write(message)
|
||||
return mac.Sum(nil)
|
||||
}
|
||||
|
||||
func setPendingSignedSessionGrant(extensionID, grant string) {
|
||||
extensionID = strings.TrimSpace(extensionID)
|
||||
grant = strings.TrimSpace(grant)
|
||||
if extensionID == "" || grant == "" {
|
||||
return
|
||||
}
|
||||
pendingSignedSessionGrantsMu.Lock()
|
||||
pendingSignedSessionGrants[extensionID] = grant
|
||||
pendingSignedSessionGrantsMu.Unlock()
|
||||
}
|
||||
Reference in New Issue
Block a user