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"} } if err := validateDownloadTransferCapability(m.Capabilities); err != nil { return &ManifestValidationError{Field: "capabilities.downloadTransfer", Message: err.Error()} } 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) }