mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-02 09:08:35 +02:00
MatchesURL treated patterns as substrings of the whole URL, so "spotify.com" matched any URL embedding it in a query parameter and a hostile query string could route a link to the wrong handler. Patterns now anchor to the URL host (exact domain or subdomain, optional path prefix); "scheme:" patterns anchor to the URI front. With several matching handlers, FindURLHandler now breaks the tie via the user's metadata provider priority instead of Go's random map order.
439 lines
14 KiB
Go
439 lines
14 KiB
Go
package gobackend
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
var extensionIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,127}$`)
|
|
|
|
type ExtensionType string
|
|
|
|
const (
|
|
ExtensionTypeMetadataProvider ExtensionType = "metadata_provider"
|
|
ExtensionTypeDownloadProvider ExtensionType = "download_provider"
|
|
ExtensionTypeLyricsProvider ExtensionType = "lyrics_provider"
|
|
)
|
|
|
|
type SettingType string
|
|
|
|
const (
|
|
SettingTypeString SettingType = "string"
|
|
SettingTypeNumber SettingType = "number"
|
|
SettingTypeBool SettingType = "boolean"
|
|
SettingTypeSelect SettingType = "select"
|
|
SettingTypeButton SettingType = "button" // Action button that calls a JS function
|
|
)
|
|
|
|
type ExtensionPermissions struct {
|
|
Network []string `json:"network"`
|
|
Storage bool `json:"storage"`
|
|
File bool `json:"file"`
|
|
AllowHTTP bool `json:"allowHttp,omitempty"`
|
|
}
|
|
|
|
type ExtensionSetting struct {
|
|
Key string `json:"key"`
|
|
Type SettingType `json:"type"`
|
|
Label string `json:"label"`
|
|
Description string `json:"description,omitempty"`
|
|
Required bool `json:"required,omitempty"`
|
|
Secret bool `json:"secret,omitempty"`
|
|
Default any `json:"default,omitempty"`
|
|
Options []string `json:"options,omitempty"`
|
|
Action string `json:"action,omitempty"`
|
|
}
|
|
|
|
type QualityOption struct {
|
|
ID string `json:"id"`
|
|
Label string `json:"label"`
|
|
Description string `json:"description"`
|
|
Settings []QualitySpecificSetting `json:"settings,omitempty"`
|
|
}
|
|
|
|
type QualitySpecificSetting struct {
|
|
Key string `json:"key"`
|
|
Type SettingType `json:"type"`
|
|
Label string `json:"label"`
|
|
Description string `json:"description,omitempty"`
|
|
Required bool `json:"required,omitempty"`
|
|
Secret bool `json:"secret,omitempty"`
|
|
Default any `json:"default,omitempty"`
|
|
Options []string `json:"options,omitempty"`
|
|
}
|
|
|
|
type SearchFilter struct {
|
|
ID string `json:"id"`
|
|
Label string `json:"label,omitempty"`
|
|
Icon string `json:"icon,omitempty"`
|
|
}
|
|
|
|
type SearchBehaviorConfig struct {
|
|
Enabled bool `json:"enabled"`
|
|
Placeholder string `json:"placeholder,omitempty"`
|
|
Primary bool `json:"primary,omitempty"`
|
|
Icon string `json:"icon,omitempty"`
|
|
ThumbnailRatio string `json:"thumbnailRatio,omitempty"`
|
|
ThumbnailWidth int `json:"thumbnailWidth,omitempty"`
|
|
ThumbnailHeight int `json:"thumbnailHeight,omitempty"`
|
|
Filters []SearchFilter `json:"filters,omitempty"`
|
|
}
|
|
|
|
type URLHandlerConfig struct {
|
|
Enabled bool `json:"enabled"`
|
|
Patterns []string `json:"patterns,omitempty"`
|
|
}
|
|
|
|
type TrackMatchingConfig struct {
|
|
CustomMatching bool `json:"customMatching"`
|
|
Strategy string `json:"strategy,omitempty"`
|
|
DurationTolerance int `json:"durationTolerance,omitempty"`
|
|
}
|
|
|
|
type PostProcessingHook struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description,omitempty"`
|
|
DefaultEnabled bool `json:"defaultEnabled,omitempty"`
|
|
SupportedFormats []string `json:"supportedFormats,omitempty"`
|
|
}
|
|
|
|
type PostProcessingConfig struct {
|
|
Enabled bool `json:"enabled"`
|
|
Hooks []PostProcessingHook `json:"hooks,omitempty"`
|
|
}
|
|
|
|
type ExtensionHealthCheck struct {
|
|
ID string `json:"id"`
|
|
Label string `json:"label,omitempty"`
|
|
URL string `json:"url"`
|
|
Method string `json:"method,omitempty"`
|
|
ServiceKey string `json:"serviceKey,omitempty"`
|
|
TimeoutMs int `json:"timeoutMs,omitempty"`
|
|
CacheTTLSeconds int `json:"cacheTtlSeconds,omitempty"`
|
|
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"`
|
|
SignedSession *SignedSessionConfig `json:"signedSession,omitempty"`
|
|
RequiredRuntimeFeatures []string `json:"requiredRuntimeFeatures,omitempty"`
|
|
Capabilities map[string]any `json:"capabilities,omitempty"`
|
|
}
|
|
|
|
type ManifestValidationError struct {
|
|
Field string
|
|
Message string
|
|
}
|
|
|
|
func (e *ManifestValidationError) Error() string {
|
|
return fmt.Sprintf("manifest validation error: %s - %s", e.Field, e.Message)
|
|
}
|
|
|
|
func ParseManifest(data []byte) (*ExtensionManifest, error) {
|
|
var manifest ExtensionManifest
|
|
if err := json.Unmarshal(data, &manifest); err != nil {
|
|
return nil, fmt.Errorf("failed to parse manifest JSON: %w", err)
|
|
}
|
|
|
|
if err := manifest.Validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &manifest, nil
|
|
}
|
|
|
|
func (m *ExtensionManifest) Validate() error {
|
|
if strings.TrimSpace(m.Name) == "" {
|
|
return &ManifestValidationError{Field: "name", Message: "name is required"}
|
|
}
|
|
if !extensionIDPattern.MatchString(m.Name) {
|
|
return &ManifestValidationError{
|
|
Field: "name",
|
|
Message: "name must be a lowercase extension ID containing only letters, numbers, '.', '_' or '-'",
|
|
}
|
|
}
|
|
|
|
if strings.TrimSpace(m.Version) == "" {
|
|
return &ManifestValidationError{Field: "version", Message: "version is required"}
|
|
}
|
|
|
|
if strings.TrimSpace(m.Description) == "" {
|
|
return &ManifestValidationError{Field: "description", Message: "description is required"}
|
|
}
|
|
|
|
if len(m.Types) == 0 {
|
|
return &ManifestValidationError{Field: "type", Message: "at least one type is required"}
|
|
}
|
|
|
|
for _, t := range m.Types {
|
|
if t != ExtensionTypeMetadataProvider && t != ExtensionTypeDownloadProvider && t != ExtensionTypeLyricsProvider {
|
|
return &ManifestValidationError{
|
|
Field: "type",
|
|
Message: fmt.Sprintf("invalid extension type: %s (must be 'metadata_provider', 'download_provider', or 'lyrics_provider')", t),
|
|
}
|
|
}
|
|
}
|
|
|
|
for i, setting := range m.Settings {
|
|
if strings.TrimSpace(setting.Key) == "" {
|
|
return &ManifestValidationError{
|
|
Field: fmt.Sprintf("settings[%d].key", i),
|
|
Message: "setting key is required",
|
|
}
|
|
}
|
|
|
|
if setting.Type == "" {
|
|
return &ManifestValidationError{
|
|
Field: fmt.Sprintf("settings[%d].type", i),
|
|
Message: "setting type is required",
|
|
}
|
|
}
|
|
|
|
if setting.Type == SettingTypeSelect && len(setting.Options) == 0 {
|
|
return &ManifestValidationError{
|
|
Field: fmt.Sprintf("settings[%d].options", i),
|
|
Message: "select type requires options",
|
|
}
|
|
}
|
|
|
|
if setting.Type == SettingTypeButton && setting.Action == "" {
|
|
return &ManifestValidationError{
|
|
Field: fmt.Sprintf("settings[%d].action", i),
|
|
Message: "button type requires action (JS function name)",
|
|
}
|
|
}
|
|
}
|
|
|
|
for i, check := range m.ServiceHealth {
|
|
if strings.TrimSpace(check.ID) == "" {
|
|
return &ManifestValidationError{
|
|
Field: fmt.Sprintf("serviceHealth[%d].id", i),
|
|
Message: "health check id is required",
|
|
}
|
|
}
|
|
if strings.TrimSpace(check.URL) == "" {
|
|
return &ManifestValidationError{
|
|
Field: fmt.Sprintf("serviceHealth[%d].url", i),
|
|
Message: "health check url is required",
|
|
}
|
|
}
|
|
method := strings.ToUpper(strings.TrimSpace(check.Method))
|
|
if method != "" && method != "GET" && method != "HEAD" {
|
|
return &ManifestValidationError{
|
|
Field: fmt.Sprintf("serviceHealth[%d].method", i),
|
|
Message: "health check method must be GET or HEAD",
|
|
}
|
|
}
|
|
}
|
|
|
|
if m.SignedSession != nil {
|
|
if !m.Permissions.Storage {
|
|
return &ManifestValidationError{Field: "permissions.storage", Message: "signedSession requires storage permission"}
|
|
}
|
|
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"}
|
|
}
|
|
}
|
|
if m.HasCapability("rawFfmpeg") && !m.Permissions.File {
|
|
return &ManifestValidationError{Field: "permissions.file", Message: "rawFfmpeg capability requires file permission"}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (m *ExtensionManifest) HasCapability(name string) bool {
|
|
if m == nil || m.Capabilities == nil {
|
|
return false
|
|
}
|
|
value, ok := m.Capabilities[name]
|
|
if !ok {
|
|
return false
|
|
}
|
|
enabled, ok := value.(bool)
|
|
return ok && enabled
|
|
}
|
|
|
|
func (m *ExtensionManifest) HasType(t ExtensionType) bool {
|
|
for _, et := range m.Types {
|
|
if et == t {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (m *ExtensionManifest) IsMetadataProvider() bool {
|
|
return m.HasType(ExtensionTypeMetadataProvider)
|
|
}
|
|
|
|
func (m *ExtensionManifest) IsDownloadProvider() bool {
|
|
return m.HasType(ExtensionTypeDownloadProvider)
|
|
}
|
|
|
|
func (m *ExtensionManifest) IsLyricsProvider() bool {
|
|
return m.HasType(ExtensionTypeLyricsProvider)
|
|
}
|
|
|
|
func (m *ExtensionManifest) StopsProviderFallback() bool {
|
|
if m == nil {
|
|
return false
|
|
}
|
|
return m.StopProviderFallback || m.SkipBuiltInFallback
|
|
}
|
|
|
|
func (m *ExtensionManifest) IsDomainAllowed(domain string) bool {
|
|
domain = strings.ToLower(strings.TrimSpace(domain))
|
|
for _, allowed := range m.Permissions.Network {
|
|
allowed = strings.ToLower(strings.TrimSpace(allowed))
|
|
if allowed == domain {
|
|
return true
|
|
}
|
|
// Support wildcard subdomains (e.g., *.example.com)
|
|
if strings.HasPrefix(allowed, "*.") {
|
|
suffix := allowed[1:]
|
|
if strings.HasSuffix(domain, suffix) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (m *ExtensionManifest) HasCustomSearch() bool {
|
|
return m.SearchBehavior != nil && m.SearchBehavior.Enabled
|
|
}
|
|
|
|
func (m *ExtensionManifest) HasCustomMatching() bool {
|
|
return m.TrackMatching != nil && m.TrackMatching.CustomMatching
|
|
}
|
|
|
|
func (m *ExtensionManifest) HasPostProcessing() bool {
|
|
return m.PostProcessing != nil && m.PostProcessing.Enabled
|
|
}
|
|
|
|
func (m *ExtensionManifest) HasURLHandler() bool {
|
|
return m.URLHandler != nil && m.URLHandler.Enabled && len(m.URLHandler.Patterns) > 0
|
|
}
|
|
|
|
// MatchesURL reports whether one of the handler's patterns matches the URL.
|
|
// Web patterns are anchored to the URL's host (exact domain or subdomain,
|
|
// optional path prefix) — never matched as a raw substring, so "spotify.com"
|
|
// cannot match a URL that merely embeds it in a query parameter. Patterns
|
|
// ending in ":" (e.g. "spotify:") match custom URI schemes as prefixes.
|
|
func (m *ExtensionManifest) MatchesURL(urlStr string) bool {
|
|
if !m.HasURLHandler() {
|
|
return false
|
|
}
|
|
|
|
urlStr = strings.ToLower(strings.TrimSpace(urlStr))
|
|
parsed, parseErr := url.Parse(urlStr)
|
|
|
|
for _, pattern := range m.URLHandler.Patterns {
|
|
pattern = strings.ToLower(strings.TrimSpace(pattern))
|
|
if pattern == "" {
|
|
continue
|
|
}
|
|
|
|
// Scheme patterns anchor to the front of the URI.
|
|
if !strings.Contains(pattern, "/") && strings.HasSuffix(pattern, ":") {
|
|
if strings.HasPrefix(urlStr, pattern) {
|
|
return true
|
|
}
|
|
continue
|
|
}
|
|
|
|
if parseErr != nil || parsed.Host == "" {
|
|
continue
|
|
}
|
|
host := parsed.Hostname()
|
|
urlPath := parsed.Path
|
|
if urlPath == "" {
|
|
urlPath = "/"
|
|
}
|
|
|
|
if idx := strings.Index(pattern, "://"); idx >= 0 {
|
|
pattern = pattern[idx+3:]
|
|
}
|
|
patternHost, patternPath, hasPath := strings.Cut(pattern, "/")
|
|
if patternHost == "" {
|
|
continue
|
|
}
|
|
if host != patternHost && !strings.HasSuffix(host, "."+patternHost) {
|
|
continue
|
|
}
|
|
if hasPath && patternPath != "" &&
|
|
!strings.HasPrefix(urlPath, "/"+patternPath) {
|
|
continue
|
|
}
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (m *ExtensionManifest) GetPostProcessingHooks() []PostProcessingHook {
|
|
if m.PostProcessing == nil {
|
|
return nil
|
|
}
|
|
return m.PostProcessing.Hooks
|
|
}
|
|
|
|
func (m *ExtensionManifest) ToJSON() ([]byte, error) {
|
|
return json.Marshal(m)
|
|
}
|