package pluginstore import ( "bytes" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "net/url" "regexp" "strings" "github.com/router-for-me/CLIProxyAPI/v7/internal/pluginhost" ) const ( DefaultRegistryURL = "https://raw.githubusercontent.com/router-for-me/CLIProxyAPI-Plugins-Store/main/registry.json" DefaultSourceID = "official" DefaultSourceName = "Official" SchemaVersion = 1 ) var pluginVersionPattern = regexp.MustCompile(`^[0-9][0-9A-Za-z.+-]*$`) type Source struct { ID string `json:"id"` Name string `json:"name"` URL string `json:"url"` } type Registry struct { SchemaVersion int `json:"schema_version"` Plugins []Plugin `json:"plugins"` } type Plugin struct { ID string `json:"id"` Name string `json:"name"` Description string `json:"description"` Author string `json:"author"` Version string `json:"version"` Repository string `json:"repository"` Logo string `json:"logo,omitempty"` Homepage string `json:"homepage,omitempty"` License string `json:"license,omitempty"` Tags []string `json:"tags,omitempty"` } func DefaultSource() Source { return Source{ ID: DefaultSourceID, Name: DefaultSourceName, URL: DefaultRegistryURL, } } func NormalizeSources(registryURLs []string) ([]Source, error) { out := []Source{DefaultSource()} seenIDs := map[string]string{DefaultSourceID: DefaultRegistryURL} seenURLs := map[string]struct{}{DefaultRegistryURL: {}} for _, registryURL := range registryURLs { registryURL = strings.TrimSpace(registryURL) if registryURL == "" { continue } if _, exists := seenURLs[registryURL]; exists { continue } source := Source{ ID: SourceID(registryURL), Name: SourceName(registryURL), URL: registryURL, } if existingURL, exists := seenIDs[source.ID]; exists { return nil, fmt.Errorf("plugin store source id collision for %q and %q", existingURL, registryURL) } seenIDs[source.ID] = registryURL seenURLs[registryURL] = struct{}{} out = append(out, source) } return out, nil } func SourceID(registryURL string) string { sum := sha256.Sum256([]byte(strings.TrimSpace(registryURL))) return "source-" + hex.EncodeToString(sum[:])[:12] } func SourceName(registryURL string) string { parsed, errParse := url.Parse(strings.TrimSpace(registryURL)) if errParse != nil || strings.TrimSpace(parsed.Host) == "" { return strings.TrimSpace(registryURL) } return parsed.Host } func ParseRegistry(data []byte) (Registry, error) { var registry Registry decoder := json.NewDecoder(bytes.NewReader(data)) if errDecode := decoder.Decode(®istry); errDecode != nil { return Registry{}, fmt.Errorf("decode registry: %w", errDecode) } normalizeRegistry(®istry) if errValidate := ValidateRegistry(registry); errValidate != nil { return Registry{}, errValidate } return registry, nil } func normalizeRegistry(registry *Registry) { if registry == nil { return } for index := range registry.Plugins { plugin := ®istry.Plugins[index] plugin.ID = strings.TrimSpace(plugin.ID) plugin.Name = strings.TrimSpace(plugin.Name) plugin.Description = strings.TrimSpace(plugin.Description) plugin.Author = strings.TrimSpace(plugin.Author) plugin.Version = strings.TrimSpace(plugin.Version) plugin.Repository = strings.TrimSpace(plugin.Repository) plugin.Logo = strings.TrimSpace(plugin.Logo) plugin.Homepage = strings.TrimSpace(plugin.Homepage) plugin.License = strings.TrimSpace(plugin.License) for tagIndex := range plugin.Tags { plugin.Tags[tagIndex] = strings.TrimSpace(plugin.Tags[tagIndex]) } } } func ValidateRegistry(registry Registry) error { if registry.SchemaVersion != SchemaVersion { return fmt.Errorf("unsupported schema_version %d", registry.SchemaVersion) } seen := make(map[string]struct{}, len(registry.Plugins)) for index, plugin := range registry.Plugins { if errValidate := ValidatePlugin(plugin); errValidate != nil { return fmt.Errorf("plugins[%d]: %w", index, errValidate) } id := strings.TrimSpace(plugin.ID) if _, exists := seen[id]; exists { return fmt.Errorf("plugins[%d]: duplicate plugin id %q", index, id) } seen[id] = struct{}{} } return nil } func ValidatePlugin(plugin Plugin) error { required := map[string]string{ "id": plugin.ID, "name": plugin.Name, "description": plugin.Description, "author": plugin.Author, "repository": plugin.Repository, } for field, value := range required { if strings.TrimSpace(value) == "" { return fmt.Errorf("missing required field %s", field) } } if !pluginhost.ValidatePluginID(strings.TrimSpace(plugin.ID)) { return fmt.Errorf("invalid plugin id %q", plugin.ID) } // The version is optional since the latest release is the source of truth; // when present it is only used as a display fallback and must be valid. if version := strings.TrimSpace(plugin.Version); version != "" && !validPluginVersion(version) { return fmt.Errorf("invalid plugin version %q", plugin.Version) } if _, _, errRepository := GitHubRepositoryParts(plugin.Repository); errRepository != nil { return errRepository } return nil } func validPluginVersion(version string) bool { return version != "" && !strings.HasPrefix(version, "v") && pluginVersionPattern.MatchString(version) } func GitHubRepositoryParts(repository string) (string, string, error) { repository = strings.TrimSpace(repository) parsed, errParse := url.Parse(repository) if errParse != nil { return "", "", fmt.Errorf("invalid repository URL: %w", errParse) } if parsed.Scheme != "https" || parsed.Host != "github.com" || parsed.RawQuery != "" || parsed.Fragment != "" { return "", "", fmt.Errorf("repository must be https://github.com/{owner}/{repo}") } segments := strings.Split(strings.Trim(parsed.EscapedPath(), "/"), "/") if len(segments) != 2 || segments[0] == "" || segments[1] == "" { return "", "", fmt.Errorf("repository must be https://github.com/{owner}/{repo}") } owner, errOwner := url.PathUnescape(segments[0]) if errOwner != nil { return "", "", fmt.Errorf("invalid repository owner: %w", errOwner) } repo, errRepo := url.PathUnescape(segments[1]) if errRepo != nil { return "", "", fmt.Errorf("invalid repository name: %w", errRepo) } if strings.HasSuffix(repo, ".git") { return "", "", fmt.Errorf("repository must be https://github.com/{owner}/{repo}") } return owner, repo, nil } func (r Registry) PluginByID(id string) (Plugin, bool) { id = strings.TrimSpace(id) for _, plugin := range r.Plugins { if strings.TrimSpace(plugin.ID) == id { return plugin, true } } return Plugin{}, false }