package alby import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "strconv" "strings" "time" "github.com/getAlby/hub/logger" "github.com/sirupsen/logrus" ) const albyInternalAPIURL = "https://getalby.com/api" // hubInfoURL is the update-check source for this fork. It points at the fork's // own git repo tags so an "update available" banner only ever appears for // versions this fork has actually published, never for upstream Alby releases. const hubInfoURL = "https://git.smesh.lol/hvat/info/refs?service=git-upload-pack" type albyService struct { } func NewAlbyService() *albyService { return &albyService{} } func (svc *albyService) GetCurrencies(ctx context.Context) ([]Currency, error) { client := &http.Client{Timeout: 10 * time.Second} url := fmt.Sprintf("%s/rates", albyInternalAPIURL) req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { logger.Logger.WithError(err).Error("Error creating request to currencies endpoint") return nil, err } setDefaultRequestHeaders(req) res, err := client.Do(req) if err != nil { logger.Logger.WithError(err).Error("Failed to fetch currencies from API") return nil, err } defer res.Body.Close() body, err := io.ReadAll(res.Body) if err != nil { logger.Logger.WithError(err).WithFields(logrus.Fields{ "url": url, }).Error("Failed to read response body") return nil, errors.New("failed to read response body") } if res.StatusCode >= 300 { logger.Logger.WithFields(logrus.Fields{ "body": string(body), "status_code": res.StatusCode, }).Error("Currencies endpoint returned non-success code") return nil, fmt.Errorf("currencies endpoint returned non-success code: %s", string(body)) } rawCurrencies := map[string]Currency{} err = json.Unmarshal(body, &rawCurrencies) if err != nil { logger.Logger.WithFields(logrus.Fields{ "body": string(body), "error": err, }).Error("Failed to decode currencies API response") return nil, err } currencies := []Currency{} for _, currency := range rawCurrencies { currencies = append(currencies, currency) } return currencies, nil } func (svc *albyService) GetBitcoinRate(ctx context.Context, currency string) (*BitcoinRate, error) { client := &http.Client{Timeout: 10 * time.Second} url := fmt.Sprintf("%s/rates/%s", albyInternalAPIURL, currency) req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { logger.Logger.WithFields(logrus.Fields{ "currency": currency, "error": err, }).Error("Error creating request to Bitcoin rate endpoint") return nil, err } setDefaultRequestHeaders(req) res, err := client.Do(req) if err != nil { logger.Logger.WithFields(logrus.Fields{ "currency": currency, "error": err, }).Error("Failed to fetch Bitcoin rate from API") return nil, err } defer res.Body.Close() body, err := io.ReadAll(res.Body) if err != nil { logger.Logger.WithError(err).WithFields(logrus.Fields{ "url": url, }).Error("Failed to read response body") return nil, errors.New("failed to read response body") } if res.StatusCode >= 300 { logger.Logger.WithFields(logrus.Fields{ "currency": currency, "body": string(body), "status_code": res.StatusCode, }).Error("Bitcoin rate endpoint returned non-success code") return nil, fmt.Errorf("bitcoin rate endpoint returned non-success code: %s", string(body)) } var rate = &BitcoinRate{} err = json.Unmarshal(body, rate) if err != nil { logger.Logger.WithFields(logrus.Fields{ "currency": currency, "body": string(body), "error": err, }).Error("Failed to decode Bitcoin rate API response") return nil, err } return rate, nil } func (svc *albyService) GetChannelPeerSuggestions(ctx context.Context) ([]ChannelPeerSuggestion, error) { client := &http.Client{Timeout: 10 * time.Second} req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/internal/channel_suggestions", albyInternalAPIURL), nil) if err != nil { logger.Logger.WithError(err).Error("Error creating request to channel_suggestions endpoint") return nil, err } setDefaultRequestHeaders(req) res, err := client.Do(req) if err != nil { logger.Logger.WithError(err).Error("Failed to fetch channel_suggestions endpoint") return nil, err } body, err := io.ReadAll(res.Body) if err != nil { logger.Logger.WithError(err).Error("Failed to read response body") return nil, errors.New("failed to read response body") } if res.StatusCode >= 300 { logger.Logger.WithFields(logrus.Fields{ "body": string(body), "status_code": res.StatusCode, }).Error("channel suggestions endpoint returned non-success code") return nil, fmt.Errorf("channel suggestions endpoint returned non-success code: %s", string(body)) } var suggestions []ChannelPeerSuggestion err = json.Unmarshal(body, &suggestions) if err != nil { logger.Logger.WithError(err).Errorf("Failed to decode API response") return nil, err } for i := range suggestions { suggestions[i].MinimumChannelSizeSat = suggestions[i].MinimumChannelSize suggestions[i].MaximumChannelSizeSat = suggestions[i].MaximumChannelSize } logger.Logger.WithFields(logrus.Fields{"channel_suggestions": suggestions}).Debug("Alby channel peer suggestions response") return suggestions, nil } func (svc *albyService) GetInfo(ctx context.Context) (*AlbyInfo, error) { client := &http.Client{Timeout: 10 * time.Second} req, err := http.NewRequestWithContext(ctx, "GET", hubInfoURL, nil) if err != nil { logger.Logger.WithError(err).Error("Error creating request to hub info endpoint") return nil, err } setDefaultRequestHeaders(req) res, err := client.Do(req) if err != nil { logger.Logger.WithError(err).Error("Failed to fetch hub info") return nil, err } defer res.Body.Close() body, err := io.ReadAll(res.Body) if err != nil { logger.Logger.WithError(err).Error("Failed to read response body") return nil, errors.New("failed to read response body") } if res.StatusCode >= 300 { logger.Logger.WithFields(logrus.Fields{ "status_code": res.StatusCode, }).Error("hub info endpoint returned non-success code") return nil, fmt.Errorf("hub info endpoint returned non-success code: %d", res.StatusCode) } latestTag := parseLatestVersionTag(string(body)) // default to 0.0.0 so an un-tagged repo never triggers an update banner latestVersion := "0.0.0" if latestTag != "" { latestVersion = strings.TrimPrefix(latestTag, "v") } return &AlbyInfo{ Hub: AlbyInfoHub{ LatestVersion: latestVersion, LatestReleaseNotes: latestTag, }, }, nil } // parseLatestVersionTag extracts the highest "vX.Y.Z" tag from git smart-HTTP // info/refs output (pkt-line encoded). func parseLatestVersionTag(refs string) string { var latest string for _, line := range strings.Split(refs, "\n") { line = strings.TrimRight(line, "\r") const marker = "refs/tags/" idx := strings.Index(line, marker) if idx < 0 { continue } tag := strings.TrimSpace(line[idx+len(marker):]) if tag == "" || strings.HasSuffix(tag, "^{}") || !strings.HasPrefix(tag, "v") { continue } if latest == "" || compareVersionTags(tag, latest) > 0 { latest = tag } } return latest } func compareVersionTags(a, b string) int { na := strings.SplitN(strings.TrimPrefix(a, "v"), "-", 2) nb := strings.SplitN(strings.TrimPrefix(b, "v"), "-", 2) pa := strings.Split(na[0], ".") pb := strings.Split(nb[0], ".") for i := 0; i < 3; i++ { va, vb := 0, 0 if i < len(pa) { va, _ = strconv.Atoi(pa[i]) } if i < len(pb) { vb, _ = strconv.Atoi(pb[i]) } if va < vb { return -1 } if va > vb { return 1 } } return 0 }