package updater import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "path" "strings" "time" "golang.org/x/text/cases" "golang.org/x/text/language" ) type Updater struct { // state storage Storage // Functions for tests logger Logger timeNow func() time.Time client *http.Client } func New(httpClient *http.Client, storage Storage, logger Logger) *Updater { return &Updater{ storage: storage, logger: logger, timeNow: time.Now, client: httpClient, } } const ( serversManifestURL = "https://raw.githubusercontent.com/qdm12/gluetun-servers/main/pkg/servers/manifest.json" serversFilesBaseURL = "https://raw.githubusercontent.com/qdm12/gluetun-servers/main/pkg/servers/" ) func (u *Updater) UpdateServers(ctx context.Context, providers []string, minRatio float64) (err error) { manifest, err := u.fetchManifest(ctx) if err != nil { return fmt.Errorf("fetching remote manifest: %w", err) } caser := cases.Title(language.English) for _, providerName := range providers { u.logger.Info("updating " + caser.String(providerName) + " servers...") // TODO support servers offering only TCP or only UDP // for NordVPN and PureVPN err := u.updateProvider(ctx, providerName, manifest, minRatio) switch { case err == nil: continue case len(providers) == 1: // return the only error for the single provider. return err case ctx.Err() != nil: // stop updating other providers if context is done return ctx.Err() default: // error encountered updating one of multiple providers // Log the error and continue updating the next provider. u.logger.Error(err.Error()) } } return nil } type manifest struct { providerToFilepath map[string]string } func (u *Updater) fetchManifest(ctx context.Context) (m manifest, err error) { var raw map[string]json.RawMessage err = u.fetchJSON(ctx, serversManifestURL, &raw) if err != nil { return m, err } m.providerToFilepath = make(map[string]string, len(raw)) for provider, rawMessage := range raw { if provider == "version" { continue } var metadata struct { Filepath string `json:"filepath"` } err = json.Unmarshal(rawMessage, &metadata) if err != nil { return m, fmt.Errorf("decoding manifest metadata for %s: %w", provider, err) } m.providerToFilepath[provider] = metadata.Filepath } return m, nil } func (u *Updater) fetchJSON(ctx context.Context, rawURL string, dst any) (err error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) if err != nil { return fmt.Errorf("creating request: %w", err) } response, err := u.client.Do(req) if err != nil { return fmt.Errorf("doing request: %w", err) } defer response.Body.Close() if response.StatusCode != http.StatusOK { const limit = 10 * 1024 * 1024 // 10 MiB body, _ := io.ReadAll(io.LimitReader(response.Body, limit)) return fmt.Errorf("HTTP status code %d for %s: %s", response.StatusCode, rawURL, strings.TrimSpace(string(body))) } err = json.NewDecoder(response.Body).Decode(dst) if err != nil { return fmt.Errorf("decoding response body: %w", err) } return nil } func buildProviderFileURL(providerName, filePath string) (providerFileURL string) { filename := path.Base(filePath) if filename == "." || filename == "/" || filename == "" { filename = providerName + ".json" } return serversFilesBaseURL + url.PathEscape(filename) }