mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-03-31 00:39:24 +02:00
Pass active download item ID through extension download pipeline so fileDownload can report bytes received/total via ItemProgressWriter. Add bytesTotal field to DownloadItem model and show X/Y MB progress in queue tab when total size is known.
436 lines
11 KiB
Go
436 lines
11 KiB
Go
package gobackend
|
|
|
|
import (
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/dop251/goja"
|
|
)
|
|
|
|
const DefaultJSTimeout = 30 * time.Second
|
|
|
|
var (
|
|
extensionAuthState = make(map[string]*ExtensionAuthState)
|
|
extensionAuthStateMu sync.RWMutex
|
|
)
|
|
|
|
type ExtensionAuthState struct {
|
|
PendingAuthURL string
|
|
AuthCode string
|
|
AccessToken string
|
|
RefreshToken string
|
|
ExpiresAt time.Time
|
|
IsAuthenticated bool
|
|
PKCEVerifier string
|
|
PKCEChallenge string
|
|
}
|
|
|
|
type PendingAuthRequest struct {
|
|
ExtensionID string
|
|
AuthURL string
|
|
CallbackURL string
|
|
}
|
|
|
|
var (
|
|
pendingAuthRequests = make(map[string]*PendingAuthRequest)
|
|
pendingAuthRequestsMu sync.RWMutex
|
|
)
|
|
|
|
func GetPendingAuthRequest(extensionID string) *PendingAuthRequest {
|
|
pendingAuthRequestsMu.RLock()
|
|
defer pendingAuthRequestsMu.RUnlock()
|
|
return pendingAuthRequests[extensionID]
|
|
}
|
|
|
|
func ClearPendingAuthRequest(extensionID string) {
|
|
pendingAuthRequestsMu.Lock()
|
|
defer pendingAuthRequestsMu.Unlock()
|
|
delete(pendingAuthRequests, extensionID)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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 != ""
|
|
}
|
|
|
|
type ExtensionRuntime struct {
|
|
extensionID string
|
|
manifest *ExtensionManifest
|
|
settings map[string]interface{}
|
|
httpClient *http.Client
|
|
downloadClient *http.Client
|
|
cookieJar http.CookieJar
|
|
dataDir string
|
|
vm *goja.Runtime
|
|
|
|
activeDownloadMu sync.RWMutex
|
|
activeDownloadItemID string
|
|
|
|
storageMu sync.RWMutex
|
|
storageCache map[string]interface{}
|
|
storageLoaded bool
|
|
storageDirty bool
|
|
storageClosed bool
|
|
storageTimer *time.Timer
|
|
storageWriteMu sync.Mutex
|
|
|
|
credentialsMu sync.RWMutex
|
|
credentialsCache map[string]interface{}
|
|
credentialsLoaded bool
|
|
storageFlushDelay time.Duration
|
|
}
|
|
|
|
type privateIPCacheEntry struct {
|
|
isPrivate bool
|
|
expiresAt time.Time
|
|
}
|
|
|
|
const (
|
|
privateIPCacheTTL = 5 * time.Minute
|
|
privateIPErrorCacheTTL = 30 * time.Second
|
|
maxPrivateIPCacheSize = 1024
|
|
)
|
|
|
|
var (
|
|
privateIPCache = make(map[string]privateIPCacheEntry)
|
|
privateIPCacheMu sync.RWMutex
|
|
)
|
|
|
|
func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
|
jar, _ := newSimpleCookieJar()
|
|
|
|
runtime := &ExtensionRuntime{
|
|
extensionID: ext.ID,
|
|
manifest: ext.Manifest,
|
|
settings: make(map[string]interface{}),
|
|
cookieJar: jar,
|
|
dataDir: ext.DataDir,
|
|
vm: ext.VM,
|
|
storageFlushDelay: defaultStorageFlushDelay,
|
|
}
|
|
|
|
runtime.httpClient = newExtensionHTTPClient(ext, jar, 30*time.Second)
|
|
runtime.downloadClient = newExtensionHTTPClient(ext, jar, DownloadTimeout)
|
|
|
|
return runtime
|
|
}
|
|
|
|
func (r *ExtensionRuntime) setActiveDownloadItemID(itemID string) {
|
|
r.activeDownloadMu.Lock()
|
|
defer r.activeDownloadMu.Unlock()
|
|
r.activeDownloadItemID = strings.TrimSpace(itemID)
|
|
}
|
|
|
|
func (r *ExtensionRuntime) clearActiveDownloadItemID() {
|
|
r.activeDownloadMu.Lock()
|
|
defer r.activeDownloadMu.Unlock()
|
|
r.activeDownloadItemID = ""
|
|
}
|
|
|
|
func (r *ExtensionRuntime) getActiveDownloadItemID() string {
|
|
r.activeDownloadMu.RLock()
|
|
defer r.activeDownloadMu.RUnlock()
|
|
return r.activeDownloadItemID
|
|
}
|
|
|
|
func newExtensionHTTPClient(ext *LoadedExtension, jar http.CookieJar, timeout time.Duration) *http.Client {
|
|
// Extension sandbox enforces HTTPS-only domains. Do not apply global
|
|
// allow_http scheme downgrade here, because some extension APIs (e.g.
|
|
// spotify-web) will redirect http -> https and can end up in 301 loops.
|
|
// We still reuse sharedTransport so insecure TLS compatibility mode remains effective.
|
|
client := &http.Client{
|
|
Transport: sharedTransport,
|
|
Timeout: timeout,
|
|
Jar: jar,
|
|
}
|
|
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
|
if req.URL.Scheme != "https" {
|
|
GoLog("[Extension:%s] Redirect blocked: non-https scheme '%s'\n", ext.ID, req.URL.Scheme)
|
|
return fmt.Errorf("redirect blocked: only https is allowed")
|
|
}
|
|
|
|
domain := req.URL.Hostname()
|
|
if domain == "" {
|
|
GoLog("[Extension:%s] Redirect blocked: missing hostname\n", ext.ID)
|
|
return fmt.Errorf("redirect blocked: hostname is required")
|
|
}
|
|
if !ext.Manifest.IsDomainAllowed(domain) {
|
|
GoLog("[Extension:%s] Redirect blocked: domain '%s' not in allowed list\n", ext.ID, domain)
|
|
return &RedirectBlockedError{Domain: domain}
|
|
}
|
|
if isPrivateIP(domain) {
|
|
GoLog("[Extension:%s] Redirect blocked: private IP '%s'\n", ext.ID, domain)
|
|
return &RedirectBlockedError{Domain: domain, IsPrivate: true}
|
|
}
|
|
if len(via) >= 10 {
|
|
return http.ErrUseLastResponse
|
|
}
|
|
return nil
|
|
}
|
|
return client
|
|
}
|
|
|
|
type RedirectBlockedError struct {
|
|
Domain string
|
|
IsPrivate bool
|
|
}
|
|
|
|
func (e *RedirectBlockedError) Error() string {
|
|
if e.IsPrivate {
|
|
return "redirect blocked: private/local network access denied"
|
|
}
|
|
return "redirect blocked: domain '" + e.Domain + "' not in allowed list"
|
|
}
|
|
|
|
func isPrivateIP(host string) bool {
|
|
hostLower := strings.ToLower(strings.TrimSpace(host))
|
|
if hostLower == "" {
|
|
return false
|
|
}
|
|
|
|
if hostLower == "localhost" || strings.HasSuffix(hostLower, ".local") {
|
|
return true
|
|
}
|
|
|
|
if ip := net.ParseIP(hostLower); ip != nil {
|
|
return isPrivateIPAddr(ip)
|
|
}
|
|
|
|
if cached, ok := getPrivateIPCache(hostLower); ok {
|
|
return cached
|
|
}
|
|
|
|
ips, err := net.LookupIP(hostLower)
|
|
if err != nil {
|
|
setPrivateIPCache(hostLower, false, privateIPErrorCacheTTL)
|
|
return false
|
|
}
|
|
|
|
isPrivate := false
|
|
for _, ip := range ips {
|
|
if isPrivateIPAddr(ip) {
|
|
isPrivate = true
|
|
break
|
|
}
|
|
}
|
|
|
|
setPrivateIPCache(hostLower, isPrivate, privateIPCacheTTL)
|
|
return isPrivate
|
|
}
|
|
|
|
func getPrivateIPCache(host string) (bool, bool) {
|
|
now := time.Now()
|
|
|
|
privateIPCacheMu.RLock()
|
|
entry, exists := privateIPCache[host]
|
|
privateIPCacheMu.RUnlock()
|
|
if !exists {
|
|
return false, false
|
|
}
|
|
|
|
if now.Before(entry.expiresAt) {
|
|
return entry.isPrivate, true
|
|
}
|
|
|
|
privateIPCacheMu.Lock()
|
|
delete(privateIPCache, host)
|
|
privateIPCacheMu.Unlock()
|
|
return false, false
|
|
}
|
|
|
|
func setPrivateIPCache(host string, isPrivate bool, ttl time.Duration) {
|
|
expiresAt := time.Now().Add(ttl)
|
|
|
|
privateIPCacheMu.Lock()
|
|
if len(privateIPCache) >= maxPrivateIPCacheSize {
|
|
now := time.Now()
|
|
for key, entry := range privateIPCache {
|
|
if now.After(entry.expiresAt) {
|
|
delete(privateIPCache, key)
|
|
}
|
|
}
|
|
if len(privateIPCache) >= maxPrivateIPCacheSize {
|
|
privateIPCache = make(map[string]privateIPCacheEntry)
|
|
}
|
|
}
|
|
privateIPCache[host] = privateIPCacheEntry{
|
|
isPrivate: isPrivate,
|
|
expiresAt: expiresAt,
|
|
}
|
|
privateIPCacheMu.Unlock()
|
|
}
|
|
|
|
func isPrivateIPAddr(ip net.IP) bool {
|
|
if ip == nil {
|
|
return false
|
|
}
|
|
if ip.IsLoopback() ||
|
|
ip.IsPrivate() ||
|
|
ip.IsLinkLocalUnicast() ||
|
|
ip.IsLinkLocalMulticast() ||
|
|
ip.IsMulticast() ||
|
|
ip.IsUnspecified() {
|
|
return true
|
|
}
|
|
if !ip.IsGlobalUnicast() {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
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]
|
|
}
|
|
|
|
func (r *ExtensionRuntime) SetSettings(settings map[string]interface{}) {
|
|
r.settings = settings
|
|
}
|
|
|
|
func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
|
r.vm = vm
|
|
|
|
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)
|
|
httpObj.Set("clearCookies", r.httpClearCookies)
|
|
vm.Set("http", httpObj)
|
|
|
|
storageObj := vm.NewObject()
|
|
storageObj.Set("get", r.storageGet)
|
|
storageObj.Set("set", r.storageSet)
|
|
storageObj.Set("remove", r.storageRemove)
|
|
vm.Set("storage", storageObj)
|
|
|
|
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)
|
|
|
|
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)
|
|
authObj.Set("generatePKCE", r.authGeneratePKCE)
|
|
authObj.Set("getPKCE", r.authGetPKCE)
|
|
authObj.Set("startOAuthWithPKCE", r.authStartOAuthWithPKCE)
|
|
authObj.Set("exchangeCodeWithPKCE", r.authExchangeCodeWithPKCE)
|
|
vm.Set("auth", authObj)
|
|
|
|
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)
|
|
|
|
ffmpegObj := vm.NewObject()
|
|
ffmpegObj.Set("execute", r.ffmpegExecute)
|
|
ffmpegObj.Set("getInfo", r.ffmpegGetInfo)
|
|
ffmpegObj.Set("convert", r.ffmpegConvert)
|
|
vm.Set("ffmpeg", ffmpegObj)
|
|
|
|
matchingObj := vm.NewObject()
|
|
matchingObj.Set("compareStrings", r.matchingCompareStrings)
|
|
matchingObj.Set("compareDuration", r.matchingCompareDuration)
|
|
matchingObj.Set("normalizeString", r.matchingNormalizeString)
|
|
vm.Set("matching", matchingObj)
|
|
|
|
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("hmacSHA256", r.hmacSHA256)
|
|
utilsObj.Set("hmacSHA256Base64", r.hmacSHA256Base64)
|
|
utilsObj.Set("hmacSHA1", r.hmacSHA1)
|
|
utilsObj.Set("parseJSON", r.parseJSON)
|
|
utilsObj.Set("stringifyJSON", r.stringifyJSON)
|
|
utilsObj.Set("encrypt", r.cryptoEncrypt)
|
|
utilsObj.Set("decrypt", r.cryptoDecrypt)
|
|
utilsObj.Set("generateKey", r.cryptoGenerateKey)
|
|
utilsObj.Set("randomUserAgent", r.randomUserAgent)
|
|
vm.Set("utils", utilsObj)
|
|
|
|
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)
|
|
|
|
gobackendObj := vm.NewObject()
|
|
gobackendObj.Set("sanitizeFilename", r.sanitizeFilenameWrapper)
|
|
vm.Set("gobackend", gobackendObj)
|
|
|
|
vm.Set("fetch", r.fetchPolyfill)
|
|
|
|
vm.Set("atob", r.atobPolyfill)
|
|
vm.Set("btoa", r.btoaPolyfill)
|
|
|
|
r.registerTextEncoderDecoder(vm)
|
|
|
|
r.registerURLClass(vm)
|
|
|
|
r.registerJSONGlobal(vm)
|
|
}
|