Files
SpotiFLAC-Mobile/go_backend/extension_repo.go
T
zarzet fd4348b803 refactor(go): rename extension store files and internals to repo
extension_store.go -> extension_repo.go, exports_store.go ->
exports_repo.go; unexported types/functions/log tag renamed to match
the user-facing repo terminology. The gomobile-exported function
names (InitExtensionStoreJSON etc.) are the bridge ABI called from
Kotlin/Swift and stay unchanged, as does the store_registry_url pref
key that guards existing users' settings.
2026-07-14 09:09:20 +07:00

558 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package gobackend
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
const (
CategoryMetadata = "metadata"
CategoryDownload = "download"
CategoryUtility = "utility"
CategoryLyrics = "lyrics"
CategoryIntegration = "integration"
)
type repoExtension struct {
ID string `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name,omitempty"`
Version string `json:"version"`
Description string `json:"description"`
DownloadURL string `json:"download_url,omitempty"`
IconURL string `json:"icon_url,omitempty"`
Category string `json:"category"`
Tags []string `json:"tags,omitempty"`
Downloads int `json:"downloads"`
UpdatedAt string `json:"updated_at"`
MinAppVersion string `json:"min_app_version,omitempty"`
DisplayNameAlt string `json:"displayName,omitempty"`
DownloadURLAlt string `json:"downloadUrl,omitempty"`
IconURLAlt string `json:"iconUrl,omitempty"`
MinAppVersionAlt string `json:"minAppVersion,omitempty"`
}
func (e *repoExtension) getDisplayName() string {
if e.DisplayName != "" {
return e.DisplayName
}
if e.DisplayNameAlt != "" {
return e.DisplayNameAlt
}
return e.Name
}
func (e *repoExtension) getDownloadURL() string {
if e.DownloadURL != "" {
return e.DownloadURL
}
return e.DownloadURLAlt
}
func (e *repoExtension) getIconURL() string {
if e.IconURL != "" {
return e.IconURL
}
return e.IconURLAlt
}
func (e *repoExtension) getMinAppVersion() string {
if e.MinAppVersion != "" {
return e.MinAppVersion
}
return e.MinAppVersionAlt
}
type repoRegistry struct {
Version int `json:"version"`
UpdatedAt string `json:"updated_at"`
Extensions []repoExtension `json:"extensions"`
}
type repoExtensionResponse struct {
ID string `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
Version string `json:"version"`
Description string `json:"description"`
DownloadURL string `json:"download_url"`
IconURL string `json:"icon_url,omitempty"`
Category string `json:"category"`
Tags []string `json:"tags,omitempty"`
Downloads int `json:"downloads"`
UpdatedAt string `json:"updated_at"`
MinAppVersion string `json:"min_app_version,omitempty"`
IsInstalled bool `json:"is_installed"`
InstalledVersion string `json:"installed_version,omitempty"`
HasUpdate bool `json:"has_update"`
}
func (e *repoExtension) toResponse() repoExtensionResponse {
resp := repoExtensionResponse{
ID: e.ID,
Name: e.Name,
DisplayName: e.getDisplayName(),
Version: e.Version,
Description: e.Description,
DownloadURL: e.getDownloadURL(),
IconURL: e.getIconURL(),
Category: e.Category,
Downloads: e.Downloads,
UpdatedAt: e.UpdatedAt,
MinAppVersion: e.getMinAppVersion(),
}
if len(e.Tags) > 0 {
resp.Tags = append([]string(nil), e.Tags...)
}
return resp
}
type extensionRepo struct {
registryURL string
cacheDir string
cache *repoRegistry
cacheMu sync.RWMutex
cacheTime time.Time
cacheTTL time.Duration
}
var (
globalExtensionRepo *extensionRepo
extensionRepoMu sync.Mutex
)
const (
cacheTTL = 30 * time.Minute
cacheFileName = "store_cache.json"
)
func initExtensionRepo(cacheDir string) *extensionRepo {
extensionRepoMu.Lock()
defer extensionRepoMu.Unlock()
if globalExtensionRepo == nil {
globalExtensionRepo = &extensionRepo{
registryURL: "",
cacheDir: cacheDir,
cacheTTL: cacheTTL,
}
globalExtensionRepo.loadDiskCache()
}
return globalExtensionRepo
}
func (s *extensionRepo) setRegistryURL(registryURL string) {
s.cacheMu.Lock()
defer s.cacheMu.Unlock()
if s.registryURL == registryURL {
return
}
s.registryURL = registryURL
s.cache = nil
s.cacheTime = time.Time{}
if s.cacheDir != "" {
cachePath := filepath.Join(s.cacheDir, cacheFileName)
os.Remove(cachePath)
}
LogInfo("ExtensionRepo", "Registry URL updated to: %s", registryURL)
}
func (s *extensionRepo) getRegistryURL() string {
s.cacheMu.RLock()
defer s.cacheMu.RUnlock()
return s.registryURL
}
func getExtensionRepo() *extensionRepo {
extensionRepoMu.Lock()
defer extensionRepoMu.Unlock()
return globalExtensionRepo
}
func (s *extensionRepo) loadDiskCache() {
if s.cacheDir == "" {
return
}
cachePath := filepath.Join(s.cacheDir, cacheFileName)
data, err := os.ReadFile(cachePath)
if err != nil {
return
}
var cacheData struct {
RegistryURL string `json:"registry_url"`
Registry repoRegistry `json:"registry"`
CacheTime int64 `json:"cache_time"`
}
if err := json.Unmarshal(data, &cacheData); err != nil {
return
}
s.cache = &cacheData.Registry
s.cacheTime = time.Unix(cacheData.CacheTime, 0)
if s.registryURL == "" {
// Restore the URL that produced this cache so a later setRegistryURL
// with the same URL keeps the cache instead of wiping it.
s.registryURL = cacheData.RegistryURL
}
LogDebug("ExtensionRepo", "Loaded %d extensions from disk cache", len(s.cache.Extensions))
}
func (s *extensionRepo) saveDiskCache() {
if s.cacheDir == "" || s.cache == nil {
return
}
cacheData := struct {
RegistryURL string `json:"registry_url"`
Registry repoRegistry `json:"registry"`
CacheTime int64 `json:"cache_time"`
}{
RegistryURL: s.registryURL,
Registry: *s.cache,
CacheTime: s.cacheTime.Unix(),
}
data, err := json.Marshal(cacheData)
if err != nil {
return
}
cachePath := filepath.Join(s.cacheDir, cacheFileName)
os.WriteFile(cachePath, data, 0644)
}
func (s *extensionRepo) fetchRegistry(forceRefresh bool) (*repoRegistry, error) {
s.cacheMu.Lock()
defer s.cacheMu.Unlock()
if s.registryURL == "" {
return nil, fmt.Errorf("no registry URL configured. Please add a repository URL first")
}
if !forceRefresh && s.cache != nil && time.Since(s.cacheTime) < s.cacheTTL {
LogDebug("ExtensionRepo", "Using cached registry (%d extensions)", len(s.cache.Extensions))
return s.cache, nil
}
if err := requireHTTPSURL(s.registryURL, "registry"); err != nil {
return nil, err
}
LogInfo("ExtensionRepo", "Fetching registry from %s", s.registryURL)
client := NewHTTPClientWithTimeout(30 * time.Second)
req, err := http.NewRequest(http.MethodGet, s.registryURL, nil)
if err != nil {
if s.cache != nil {
LogWarn("ExtensionRepo", "Failed to build registry request, using cached registry: %v", err)
return s.cache, nil
}
return nil, fmt.Errorf("failed to build registry request: %w", err)
}
req.Header.Set("Cache-Control", "no-cache")
req.Header.Set("Pragma", "no-cache")
resp, err := client.Do(req)
if err != nil {
if s.cache != nil {
LogWarn("ExtensionRepo", "Network error, using cached registry: %v", err)
return s.cache, nil
}
return nil, fmt.Errorf("failed to fetch registry: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
if s.cache != nil {
LogWarn("ExtensionRepo", "HTTP %d, using cached registry", resp.StatusCode)
return s.cache, nil
}
return nil, fmt.Errorf("registry returned HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read registry: %w", err)
}
registry, err := parseRegistryBody(body)
if err != nil {
if s.cache != nil {
LogWarn("ExtensionRepo", "Failed to parse registry, using cached registry: %v", err)
return s.cache, nil
}
return nil, err
}
s.cache = registry
s.cacheTime = time.Now()
s.saveDiskCache()
LogInfo("ExtensionRepo", "Fetched %d extensions from registry", len(registry.Extensions))
return registry, nil
}
func parseRegistryBody(body []byte) (*repoRegistry, error) {
var registry repoRegistry
if err := json.Unmarshal(body, &registry); err != nil {
if strings.HasPrefix(strings.TrimSpace(string(body)), "<") {
return nil, fmt.Errorf("registry URL returned a web page instead of JSON. Make sure the URL points to a registry.json file or a GitHub repository that contains one")
}
return nil, fmt.Errorf("failed to parse registry: %w", err)
}
return &registry, nil
}
func (s *extensionRepo) getExtensionsWithStatus(forceRefresh bool) ([]repoExtensionResponse, error) {
registry, err := s.fetchRegistry(forceRefresh)
if err != nil {
return nil, err
}
manager := getExtensionManager()
installed := make(map[string]string) // id -> version
if manager != nil {
for _, ext := range manager.GetAllExtensions() {
installed[ext.ID] = ext.Manifest.Version
}
}
LogDebug("ExtensionRepo", "Building store response for %d registry extensions (%d installed)", len(registry.Extensions), len(installed))
result := make([]repoExtensionResponse, 0, len(registry.Extensions))
for i := range registry.Extensions {
ext := &registry.Extensions[i]
resp := ext.toResponse()
if installedVersion, ok := installed[ext.ID]; ok {
resp.IsInstalled = true
resp.InstalledVersion = installedVersion
resp.HasUpdate = compareVersions(ext.Version, installedVersion) > 0
}
result = append(result, resp)
}
LogDebug("ExtensionRepo", "Built store response payload for %d extensions", len(result))
return result, nil
}
func (s *extensionRepo) findExtension(extensionID string) (*repoExtension, error) {
registry, err := s.fetchRegistry(false)
if err != nil {
return nil, err
}
for _, e := range registry.Extensions {
if e.ID == extensionID {
ext := e
return &ext, nil
}
}
return nil, fmt.Errorf("extension %s not found in repo", extensionID)
}
func (s *extensionRepo) downloadExtension(extensionID string, destPath string) error {
ext, err := s.findExtension(extensionID)
if err != nil {
return err
}
if err := requireHTTPSURL(ext.getDownloadURL(), "extension download"); err != nil {
return err
}
LogInfo("ExtensionRepo", "Downloading %s from %s", ext.getDisplayName(), ext.getDownloadURL())
client := NewHTTPClientWithTimeout(5 * time.Minute)
req, err := http.NewRequest(http.MethodGet, ext.getDownloadURL(), nil)
if err != nil {
return fmt.Errorf("failed to build download request: %w", err)
}
req.Header.Set("Cache-Control", "no-cache")
req.Header.Set("Pragma", "no-cache")
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to download: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download returned HTTP %d", resp.StatusCode)
}
out, err := os.Create(destPath)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
if err != nil {
os.Remove(destPath)
return fmt.Errorf("failed to write file: %w", err)
}
LogInfo("ExtensionRepo", "Downloaded %s to %s", ext.getDisplayName(), destPath)
return nil
}
func resolveRegistryURL(input string) (string, error) {
input = strings.TrimSpace(input)
if input == "" {
return "", fmt.Errorf("registry URL is empty")
}
if strings.Contains(input, "raw.githubusercontent.com") {
return input, nil
}
const ghPrefix = "https://github.com/"
if !strings.HasPrefix(input, ghPrefix) {
const ghPrefixHTTP = "http://github.com/"
if strings.HasPrefix(input, ghPrefixHTTP) {
input = "https://github.com/" + input[len(ghPrefixHTTP):]
} else {
return input, nil
}
}
path := input[len(ghPrefix):]
parts := strings.SplitN(path, "/", 3) // owner, repo, [rest]
if len(parts) < 2 || parts[0] == "" || parts[1] == "" {
return "", fmt.Errorf("invalid GitHub URL: expected github.com/<owner>/<repo>")
}
owner := parts[0]
repo := strings.TrimSuffix(parts[1], ".git")
branch := resolveGitHubDefaultBranch(owner, repo)
resolved := fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s/registry.json", owner, repo, branch)
LogInfo("ExtensionRepo", "Resolved %s → %s (branch: %s)", input, resolved, branch)
return resolved, nil
}
func resolveGitHubDefaultBranch(owner, repo string) string {
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s", owner, repo)
client := NewHTTPClientWithTimeout(10 * time.Second)
resp, err := client.Get(apiURL)
if err != nil {
LogWarn("ExtensionRepo", "GitHub API request failed for %s/%s: %v falling back to main", owner, repo, err)
return "main"
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
LogWarn("ExtensionRepo", "GitHub API returned %d for %s/%s falling back to main", resp.StatusCode, owner, repo)
return "main"
}
var info struct {
DefaultBranch string `json:"default_branch"`
}
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil || info.DefaultBranch == "" {
LogWarn("ExtensionRepo", "Could not parse default_branch for %s/%s falling back to main", owner, repo)
return "main"
}
return info.DefaultBranch
}
func requireHTTPSURL(rawURL string, context string) error {
if rawURL == "" {
return fmt.Errorf("%s URL is empty", context)
}
parsed, err := url.Parse(rawURL)
if err != nil || parsed.Host == "" {
return fmt.Errorf("%s URL is invalid: %s", context, rawURL)
}
if parsed.Scheme != "https" {
return fmt.Errorf("%s URL must use https: %s", context, rawURL)
}
return nil
}
func (s *extensionRepo) getCategories() []string {
return []string{
CategoryMetadata,
CategoryDownload,
CategoryUtility,
CategoryLyrics,
CategoryIntegration,
}
}
func (s *extensionRepo) searchExtensions(query string, category string) ([]repoExtensionResponse, error) {
extensions, err := s.getExtensionsWithStatus(false)
if err != nil {
return nil, err
}
if query == "" && category == "" {
return extensions, nil
}
result := make([]repoExtensionResponse, 0, len(extensions))
queryLower := strings.ToLower(query)
for _, ext := range extensions {
if category != "" && ext.Category != category {
continue
}
if query != "" {
if !strings.Contains(strings.ToLower(ext.Name), queryLower) &&
!strings.Contains(strings.ToLower(ext.DisplayName), queryLower) &&
!strings.Contains(strings.ToLower(ext.Description), queryLower) {
found := false
for _, tag := range ext.Tags {
if strings.Contains(strings.ToLower(tag), queryLower) {
found = true
break
}
}
if !found {
continue
}
}
}
result = append(result, ext)
}
return result, nil
}
func (s *extensionRepo) clearCache() {
s.cacheMu.Lock()
defer s.cacheMu.Unlock()
s.cache = nil
s.cacheTime = time.Time{}
if s.cacheDir != "" {
cachePath := filepath.Join(s.cacheDir, cacheFileName)
os.Remove(cachePath)
}
LogInfo("ExtensionRepo", "Cache cleared")
}