alby_service.go raw

   1  package alby
   2  
   3  import (
   4  	"context"
   5  	"encoding/json"
   6  	"errors"
   7  	"fmt"
   8  	"io"
   9  	"net/http"
  10  	"strconv"
  11  	"strings"
  12  	"time"
  13  
  14  	"github.com/getAlby/hub/logger"
  15  	"github.com/sirupsen/logrus"
  16  )
  17  
  18  const albyInternalAPIURL = "https://getalby.com/api"
  19  
  20  // hubInfoURL is the update-check source for this fork. It points at the fork's
  21  // own git repo tags so an "update available" banner only ever appears for
  22  // versions this fork has actually published, never for upstream Alby releases.
  23  const hubInfoURL = "https://git.smesh.lol/hvat/info/refs?service=git-upload-pack"
  24  
  25  type albyService struct {
  26  }
  27  
  28  func NewAlbyService() *albyService {
  29  	return &albyService{}
  30  }
  31  
  32  func (svc *albyService) GetCurrencies(ctx context.Context) ([]Currency, error) {
  33  	client := &http.Client{Timeout: 10 * time.Second}
  34  	url := fmt.Sprintf("%s/rates", albyInternalAPIURL)
  35  
  36  	req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
  37  	if err != nil {
  38  		logger.Logger.WithError(err).Error("Error creating request to currencies endpoint")
  39  		return nil, err
  40  	}
  41  	setDefaultRequestHeaders(req)
  42  
  43  	res, err := client.Do(req)
  44  	if err != nil {
  45  		logger.Logger.WithError(err).Error("Failed to fetch currencies from API")
  46  		return nil, err
  47  	}
  48  
  49  	defer res.Body.Close()
  50  
  51  	body, err := io.ReadAll(res.Body)
  52  	if err != nil {
  53  		logger.Logger.WithError(err).WithFields(logrus.Fields{
  54  			"url": url,
  55  		}).Error("Failed to read response body")
  56  		return nil, errors.New("failed to read response body")
  57  	}
  58  
  59  	if res.StatusCode >= 300 {
  60  		logger.Logger.WithFields(logrus.Fields{
  61  			"body":        string(body),
  62  			"status_code": res.StatusCode,
  63  		}).Error("Currencies endpoint returned non-success code")
  64  		return nil, fmt.Errorf("currencies endpoint returned non-success code: %s", string(body))
  65  	}
  66  
  67  	rawCurrencies := map[string]Currency{}
  68  	err = json.Unmarshal(body, &rawCurrencies)
  69  	if err != nil {
  70  		logger.Logger.WithFields(logrus.Fields{
  71  			"body":  string(body),
  72  			"error": err,
  73  		}).Error("Failed to decode currencies API response")
  74  		return nil, err
  75  	}
  76  
  77  	currencies := []Currency{}
  78  	for _, currency := range rawCurrencies {
  79  		currencies = append(currencies, currency)
  80  	}
  81  
  82  	return currencies, nil
  83  }
  84  
  85  func (svc *albyService) GetBitcoinRate(ctx context.Context, currency string) (*BitcoinRate, error) {
  86  	client := &http.Client{Timeout: 10 * time.Second}
  87  
  88  	url := fmt.Sprintf("%s/rates/%s", albyInternalAPIURL, currency)
  89  
  90  	req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
  91  	if err != nil {
  92  		logger.Logger.WithFields(logrus.Fields{
  93  			"currency": currency,
  94  			"error":    err,
  95  		}).Error("Error creating request to Bitcoin rate endpoint")
  96  		return nil, err
  97  	}
  98  	setDefaultRequestHeaders(req)
  99  
 100  	res, err := client.Do(req)
 101  	if err != nil {
 102  		logger.Logger.WithFields(logrus.Fields{
 103  			"currency": currency,
 104  			"error":    err,
 105  		}).Error("Failed to fetch Bitcoin rate from API")
 106  		return nil, err
 107  	}
 108  
 109  	defer res.Body.Close()
 110  
 111  	body, err := io.ReadAll(res.Body)
 112  	if err != nil {
 113  		logger.Logger.WithError(err).WithFields(logrus.Fields{
 114  			"url": url,
 115  		}).Error("Failed to read response body")
 116  		return nil, errors.New("failed to read response body")
 117  	}
 118  
 119  	if res.StatusCode >= 300 {
 120  		logger.Logger.WithFields(logrus.Fields{
 121  			"currency":    currency,
 122  			"body":        string(body),
 123  			"status_code": res.StatusCode,
 124  		}).Error("Bitcoin rate endpoint returned non-success code")
 125  		return nil, fmt.Errorf("bitcoin rate endpoint returned non-success code: %s", string(body))
 126  	}
 127  
 128  	var rate = &BitcoinRate{}
 129  	err = json.Unmarshal(body, rate)
 130  	if err != nil {
 131  		logger.Logger.WithFields(logrus.Fields{
 132  			"currency": currency,
 133  			"body":     string(body),
 134  			"error":    err,
 135  		}).Error("Failed to decode Bitcoin rate API response")
 136  		return nil, err
 137  	}
 138  
 139  	return rate, nil
 140  }
 141  
 142  func (svc *albyService) GetChannelPeerSuggestions(ctx context.Context) ([]ChannelPeerSuggestion, error) {
 143  	client := &http.Client{Timeout: 10 * time.Second}
 144  
 145  	req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/internal/channel_suggestions", albyInternalAPIURL), nil)
 146  	if err != nil {
 147  		logger.Logger.WithError(err).Error("Error creating request to channel_suggestions endpoint")
 148  		return nil, err
 149  	}
 150  
 151  	setDefaultRequestHeaders(req)
 152  
 153  	res, err := client.Do(req)
 154  	if err != nil {
 155  		logger.Logger.WithError(err).Error("Failed to fetch channel_suggestions endpoint")
 156  		return nil, err
 157  	}
 158  
 159  	body, err := io.ReadAll(res.Body)
 160  	if err != nil {
 161  		logger.Logger.WithError(err).Error("Failed to read response body")
 162  		return nil, errors.New("failed to read response body")
 163  	}
 164  
 165  	if res.StatusCode >= 300 {
 166  		logger.Logger.WithFields(logrus.Fields{
 167  			"body":        string(body),
 168  			"status_code": res.StatusCode,
 169  		}).Error("channel suggestions endpoint returned non-success code")
 170  		return nil, fmt.Errorf("channel suggestions endpoint returned non-success code: %s", string(body))
 171  	}
 172  
 173  	var suggestions []ChannelPeerSuggestion
 174  	err = json.Unmarshal(body, &suggestions)
 175  	if err != nil {
 176  		logger.Logger.WithError(err).Errorf("Failed to decode API response")
 177  		return nil, err
 178  	}
 179  
 180  	for i := range suggestions {
 181  		suggestions[i].MinimumChannelSizeSat = suggestions[i].MinimumChannelSize
 182  		suggestions[i].MaximumChannelSizeSat = suggestions[i].MaximumChannelSize
 183  	}
 184  
 185  	logger.Logger.WithFields(logrus.Fields{"channel_suggestions": suggestions}).Debug("Alby channel peer suggestions response")
 186  	return suggestions, nil
 187  }
 188  
 189  func (svc *albyService) GetInfo(ctx context.Context) (*AlbyInfo, error) {
 190  	client := &http.Client{Timeout: 10 * time.Second}
 191  
 192  	req, err := http.NewRequestWithContext(ctx, "GET", hubInfoURL, nil)
 193  	if err != nil {
 194  		logger.Logger.WithError(err).Error("Error creating request to hub info endpoint")
 195  		return nil, err
 196  	}
 197  
 198  	setDefaultRequestHeaders(req)
 199  
 200  	res, err := client.Do(req)
 201  	if err != nil {
 202  		logger.Logger.WithError(err).Error("Failed to fetch hub info")
 203  		return nil, err
 204  	}
 205  	defer res.Body.Close()
 206  
 207  	body, err := io.ReadAll(res.Body)
 208  	if err != nil {
 209  		logger.Logger.WithError(err).Error("Failed to read response body")
 210  		return nil, errors.New("failed to read response body")
 211  	}
 212  
 213  	if res.StatusCode >= 300 {
 214  		logger.Logger.WithFields(logrus.Fields{
 215  			"status_code": res.StatusCode,
 216  		}).Error("hub info endpoint returned non-success code")
 217  		return nil, fmt.Errorf("hub info endpoint returned non-success code: %d", res.StatusCode)
 218  	}
 219  
 220  	latestTag := parseLatestVersionTag(string(body))
 221  
 222  	// default to 0.0.0 so an un-tagged repo never triggers an update banner
 223  	latestVersion := "0.0.0"
 224  	if latestTag != "" {
 225  		latestVersion = strings.TrimPrefix(latestTag, "v")
 226  	}
 227  
 228  	return &AlbyInfo{
 229  		Hub: AlbyInfoHub{
 230  			LatestVersion:      latestVersion,
 231  			LatestReleaseNotes: latestTag,
 232  		},
 233  	}, nil
 234  }
 235  
 236  // parseLatestVersionTag extracts the highest "vX.Y.Z" tag from git smart-HTTP
 237  // info/refs output (pkt-line encoded).
 238  func parseLatestVersionTag(refs string) string {
 239  	var latest string
 240  	for _, line := range strings.Split(refs, "\n") {
 241  		line = strings.TrimRight(line, "\r")
 242  		const marker = "refs/tags/"
 243  		idx := strings.Index(line, marker)
 244  		if idx < 0 {
 245  			continue
 246  		}
 247  		tag := strings.TrimSpace(line[idx+len(marker):])
 248  		if tag == "" || strings.HasSuffix(tag, "^{}") || !strings.HasPrefix(tag, "v") {
 249  			continue
 250  		}
 251  		if latest == "" || compareVersionTags(tag, latest) > 0 {
 252  			latest = tag
 253  		}
 254  	}
 255  	return latest
 256  }
 257  
 258  func compareVersionTags(a, b string) int {
 259  	na := strings.SplitN(strings.TrimPrefix(a, "v"), "-", 2)
 260  	nb := strings.SplitN(strings.TrimPrefix(b, "v"), "-", 2)
 261  	pa := strings.Split(na[0], ".")
 262  	pb := strings.Split(nb[0], ".")
 263  	for i := 0; i < 3; i++ {
 264  		va, vb := 0, 0
 265  		if i < len(pa) {
 266  			va, _ = strconv.Atoi(pa[i])
 267  		}
 268  		if i < len(pb) {
 269  			vb, _ = strconv.Atoi(pb[i])
 270  		}
 271  		if va < vb {
 272  			return -1
 273  		}
 274  		if va > vb {
 275  			return 1
 276  		}
 277  	}
 278  	return 0
 279  }
 280