adding support for user settings (Remote Dashboard Locations)

This commit is contained in:
Jason Kulatunga 2023-08-24 12:28:57 -07:00
parent a0bce3976a
commit 31479a4fc4
10 changed files with 411 additions and 78 deletions

View File

@ -10,6 +10,8 @@ import (
"strings"
)
const DB_USER_SETTINGS_SUBKEY = "user"
// When initializing this class the following methods must be called:
// Config.New
// Config.Init
@ -30,8 +32,6 @@ func (c *configuration) Init() error {
c.SetDefault("web.allow_unsafe_endpoints", false)
c.SetDefault("web.src.frontend.path", "/opt/fasten/web")
c.SetDefault("dashboard.location", []string{})
c.SetDefault("database.location", "/opt/fasten/db/fasten.db")
c.SetDefault("jwt.issuer.key", "thisismysupersecuressessionsecretlength")

View File

@ -11,6 +11,7 @@ type Interface interface {
ReadConfig(configFilePath string) error
Set(key string, value interface{})
SetDefault(key string, value interface{})
MergeConfigMap(cfg map[string]interface{}) error
AllSettings() map[string]interface{}
IsSet(key string) bool
Get(key string) interface{}

View File

@ -5,6 +5,7 @@ import (
sourcePkg "github.com/fastenhealth/fasten-sources/clients/models"
"github.com/fastenhealth/fastenhealth-onprem/backend/pkg"
"github.com/fastenhealth/fastenhealth-onprem/backend/pkg/models"
"github.com/google/uuid"
)
//go:generate mockgen -source=interface.go -destination=mock/mock_database.go
@ -41,6 +42,11 @@ type DatabaseRepository interface {
CreateGlossaryEntry(ctx context.Context, glossaryEntry *models.Glossary) error
GetGlossaryEntry(ctx context.Context, code string, codeSystem string) (*models.Glossary, error)
//settings
LoadUserSettings(ctx context.Context) (*models.UserSettings, error)
SaveUserSettings(context.Context, *models.UserSettings) error
PopulateDefaultUserSettings(ctx context.Context, userId uuid.UUID) error
//used by fasten-sources Clients
UpsertRawResource(ctx context.Context, sourceCredentials sourcePkg.SourceCredential, rawResource sourcePkg.RawResourceFhir) (bool, error)
}

View File

@ -99,6 +99,7 @@ func (sr *SqliteRepository) Migrate() error {
&models.User{},
&models.SourceCredential{},
&models.Glossary{},
&models.UserSettingEntry{},
)
if err != nil {
return fmt.Errorf("Failed to automigrate! - %v", err)
@ -122,6 +123,12 @@ func (sr *SqliteRepository) CreateUser(ctx context.Context, user *models.User) e
if record.Error != nil {
return record.Error
}
//create user settings
err := sr.PopulateDefaultUserSettings(ctx, user.ID)
if err != nil {
return err
}
return nil
}
func (sr *SqliteRepository) GetUserByUsername(ctx context.Context, username string) (*models.User, error) {

View File

@ -0,0 +1,133 @@
package database
import (
"context"
"fmt"
"github.com/fastenhealth/fastenhealth-onprem/backend/pkg/config"
"github.com/fastenhealth/fastenhealth-onprem/backend/pkg/models"
"github.com/google/uuid"
"github.com/mitchellh/mapstructure"
"strings"
)
// LoadSettings will retrieve settings from the database, store them in the AppConfig object, and return a Settings struct
func (sr *SqliteRepository) LoadUserSettings(ctx context.Context) (*models.UserSettings, error) {
currentUser, currentUserErr := sr.GetCurrentUser(ctx)
if currentUserErr != nil {
return nil, currentUserErr
}
settingsEntries := []models.UserSettingEntry{}
if err := sr.GormClient.
WithContext(ctx).
Where(models.UserSettingEntry{
UserID: currentUser.ID,
}).
Find(&settingsEntries).Error; err != nil {
return nil, fmt.Errorf("Could not get settings from DB: %v", err)
}
// store retrieved settings in the AppConfig obj
for _, settingsEntry := range settingsEntries {
configKey := fmt.Sprintf("%s.%s", config.DB_USER_SETTINGS_SUBKEY, settingsEntry.SettingKeyName)
if settingsEntry.SettingDataType == "numeric" {
sr.AppConfig.SetDefault(configKey, settingsEntry.SettingValueNumeric)
} else if settingsEntry.SettingDataType == "string" {
sr.AppConfig.SetDefault(configKey, settingsEntry.SettingValueString)
} else if settingsEntry.SettingDataType == "bool" {
sr.AppConfig.SetDefault(configKey, settingsEntry.SettingValueBool)
} else if settingsEntry.SettingDataType == "array" {
sr.AppConfig.SetDefault(configKey, settingsEntry.SettingValueArray)
}
}
// unmarshal the dbsetting object data to a settings object.
var settings models.UserSettings
err := sr.AppConfig.UnmarshalKey(config.DB_USER_SETTINGS_SUBKEY, &settings)
if err != nil {
return nil, err
}
return &settings, nil
}
// testing
// curl -d '{"metrics": { "notify_level": 5, "status_filter_attributes": 5, "status_threshold": 5 }}' -H "Content-Type: application/json" -X POST http://localhost:9090/api/settings
// SaveSettings will update settings in AppConfig object, then save the settings to the database.
func (sr *SqliteRepository) SaveUserSettings(ctx context.Context, settings *models.UserSettings) error {
currentUser, currentUserErr := sr.GetCurrentUser(ctx)
if currentUserErr != nil {
return currentUserErr
}
//save the entries to the appconfig
settingsMap := &map[string]interface{}{}
err := mapstructure.Decode(settings, &settingsMap)
if err != nil {
return err
}
settingsWrapperMap := map[string]interface{}{}
settingsWrapperMap[config.DB_USER_SETTINGS_SUBKEY] = *settingsMap
err = sr.AppConfig.MergeConfigMap(settingsWrapperMap)
if err != nil {
return err
}
sr.Logger.Debugf("after merge settings: %v", sr.AppConfig.AllSettings())
//retrieve current settings from the database
settingsEntries := []models.UserSettingEntry{}
if err := sr.GormClient.
WithContext(ctx).
Where(models.UserSettingEntry{
UserID: currentUser.ID,
}).
Find(&settingsEntries).Error; err != nil {
return fmt.Errorf("Could not get settings from DB: %v", err)
}
//update settingsEntries
for ndx, settingsEntry := range settingsEntries {
configKey := fmt.Sprintf("%s.%s", config.DB_USER_SETTINGS_SUBKEY, strings.ToLower(settingsEntry.SettingKeyName))
if !sr.AppConfig.IsSet(configKey) {
continue //skip any settings that don't exist in the appconfig
}
if settingsEntry.SettingDataType == "numeric" {
settingsEntries[ndx].SettingValueNumeric = sr.AppConfig.GetInt(configKey)
} else if settingsEntry.SettingDataType == "string" {
settingsEntries[ndx].SettingValueString = sr.AppConfig.GetString(configKey)
} else if settingsEntry.SettingDataType == "bool" {
settingsEntries[ndx].SettingValueBool = sr.AppConfig.GetBool(configKey)
} else if settingsEntry.SettingDataType == "array" {
settingsEntries[ndx].SettingValueArray = sr.AppConfig.GetStringSlice(configKey)
}
// store in database.
//TODO: this should be `sr.gormClient.Updates(&settingsEntries).Error`
err := sr.GormClient.
WithContext(ctx).
Model(&models.UserSettingEntry{}).
Where([]uuid.UUID{settingsEntry.ID}).
Select("setting_value_numeric", "setting_value_string", "setting_value_bool").
Updates(settingsEntries[ndx]).Error
if err != nil {
return err
}
}
return nil
}
func (sr *SqliteRepository) PopulateDefaultUserSettings(ctx context.Context, userId uuid.UUID) error {
//retrieve current settings from the database
settingsEntries := []models.UserSettingEntry{}
settingsEntries = append(settingsEntries, models.UserSettingEntry{
UserID: userId,
SettingKeyName: "dashboard_locations",
SettingKeyDescription: "customized dashboard json locations",
SettingDataType: "array",
SettingValueArray: []string{},
})
return sr.GormClient.WithContext(ctx).Create(settingsEntries).Error
}

View File

@ -0,0 +1,26 @@
package models
import (
"github.com/google/uuid"
)
// SettingEntry matches a setting row in the database
type UserSettingEntry struct {
//GORM attributes, see: http://gorm.io/docs/conventions.html
ModelBase
User *User `json:"user,omitempty" gorm:"-"`
UserID uuid.UUID `json:"user_id" gorm:"not null;index:,unique,composite:user_setting_key_name"`
SettingKeyName string `json:"setting_key_name" gorm:"not null;index:,unique,composite:user_setting_key_name"`
SettingKeyDescription string `json:"setting_key_description"`
SettingDataType string `json:"setting_data_type"`
SettingValueNumeric int `json:"setting_value_numeric"`
SettingValueString string `json:"setting_value_string"`
SettingValueBool bool `json:"setting_value_bool"`
SettingValueArray []string `json:"setting_value_array" gorm:"column:setting_value_array;type:text;serializer:json"`
}
func (s UserSettingEntry) TableName() string {
return "user_settings"
}

View File

@ -0,0 +1,5 @@
package models
type UserSettings struct {
DashboardLocations []string `json:"dashboard_locations" mapstructure:"dashboard_locations"`
}

View File

@ -1,13 +1,16 @@
package handler
import (
"context"
"embed"
"encoding/json"
"fmt"
"github.com/fastenhealth/fastenhealth-onprem/backend/pkg"
"github.com/fastenhealth/fastenhealth-onprem/backend/pkg/config"
"github.com/fastenhealth/fastenhealth-onprem/backend/pkg/database"
"github.com/gin-gonic/gin"
"github.com/google/go-github/v54/github"
"github.com/sirupsen/logrus"
"golang.org/x/exp/slices"
"io/fs"
"net/http"
"os"
@ -20,64 +23,52 @@ var dashboardFS embed.FS
func GetDashboard(c *gin.Context) {
logger := c.MustGet(pkg.ContextKeyTypeLogger).(*logrus.Entry)
appConfig := c.MustGet(pkg.ContextKeyTypeConfig).(config.Interface)
//appConfig := c.MustGet(pkg.ContextKeyTypeConfig).(config.Interface)
databaseRepo := c.MustGet(pkg.ContextKeyTypeDatabase).(database.DatabaseRepository)
var dirEntries []fs.DirEntry
var err error
//load settings from dashboard
logger.Infof("Loading User Settings..")
userSettings, err := databaseRepo.LoadUserSettings(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
return
}
logger.Debugf("User Settings: %v", userSettings)
//get current user
currentUser, err := databaseRepo.GetCurrentUser(c)
if err != nil {
logger.Errorf("Error getting current user: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
return
}
var dashboards []map[string]interface{}
if dashboardLocations := appConfig.GetStringSlice("dashboard.location"); dashboardLocations != nil && len(dashboardLocations) > 0 {
logger.Infof("Loading dashboard(s) from %v", dashboardLocations)
if userSettings.DashboardLocations != nil && len(userSettings.DashboardLocations) > 0 {
logger.Infof("Loading dashboard(s) from %v", userSettings.DashboardLocations)
// TODO: these should be populated from the user settings table (each user can have their own dashboards).
// TODO: when enabled, used the following algorithm:
//- validate that the url is to a github gist, no other locations are supported
//- download the gist metadata
//- if more than 1 file found, look for a dashboard.json
//- check if the file sha exists on the file system (content-addressible file system)
//- if its not present, download it
//- if its not json, throw an error
//- if it doesnt match the dashboard config schema, throw an error.
// initialize the cache directory
cacheDir, err := getCacheDir(currentUser.ID.String())
if err != nil {
logger.Errorf("Error creating cache directory: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
return
}
for _, dashboardLocation := range dashboardLocations {
if strings.HasPrefix(dashboardLocation, "http") {
c.JSON(http.StatusOK, gin.H{"success": false, "error": fmt.Sprintf("Remote Dashboard URL's are not supported yet: %v", dashboardLocations)})
return
}
//when using `dashboard.locations` config key, each dashboard should be specified individually
//e.g. dashboard.locations = ["/opt/fasten/dashboard/test.json", "/opt/fasten/dashboard/test2.json"]
absDashboardLocation, err := filepath.Abs(dashboardLocation)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": fmt.Sprintf("Invalid dashboard location: %v", dashboardLocation)})
return
}
//check if path exists
if _, err := os.Stat(absDashboardLocation); err != nil {
//file does not exist
c.JSON(http.StatusOK, gin.H{"success": false, "error": fmt.Sprintf("Dashboard file does not exist: %v", absDashboardLocation)})
return
}
//open file
file, err := os.ReadFile(absDashboardLocation)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
return
}
//unmarshall json into map
var dashboardJson map[string]interface{}
err = json.Unmarshal(file, &dashboardJson)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
return
}
dashboards = append(dashboards, dashboardJson)
dirEntries, err = os.ReadDir(cacheDir)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
return
}
dashboards, err = getDashboardFromDir(dirEntries, os.ReadFile)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
return
}
} else {
dirEntries, err = dashboardFS.ReadDir("dashboard")
@ -86,7 +77,7 @@ func GetDashboard(c *gin.Context) {
return
}
dashboards, err = getDashboardFromEmbeddedDir(dirEntries)
dashboards, err = getDashboardFromDir(dirEntries, dashboardFS.ReadFile)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
return
@ -96,7 +87,157 @@ func GetDashboard(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "data": dashboards})
}
func getDashboardFromEmbeddedDir(dirEntries []fs.DirEntry) ([]map[string]interface{}, error) {
func SaveDashboardLocations(c *gin.Context) {
logger := c.MustGet(pkg.ContextKeyTypeLogger).(*logrus.Entry)
//appConfig := c.MustGet(pkg.ContextKeyTypeConfig).(config.Interface)
databaseRepo := c.MustGet(pkg.ContextKeyTypeDatabase).(database.DatabaseRepository)
//load settings from database
logger.Infof("Loading User Settings..")
userSettings, err := databaseRepo.LoadUserSettings(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
return
}
//override locations with new locations
userSettings.DashboardLocations = c.PostFormArray("dashboardLocations")
logger.Debugf("User Settings: %v", userSettings)
//get current user
currentUser, err := databaseRepo.GetCurrentUser(c)
if err != nil {
logger.Errorf("Error getting current user: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
return
}
cacheDir, err := getCacheDir(currentUser.ID.String())
if err != nil {
logger.Errorf("Error creating cache directory: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
return
}
// initialize the github client (Anonymous access)
githubClient := github.NewClient(nil)
cacheErrors := map[string]error{}
cacheDashboards := []string{}
for _, remoteDashboardLocation := range userSettings.DashboardLocations {
cacheDashboardLocation, err := cacheCustomDashboard(logger, githubClient, cacheDir, remoteDashboardLocation)
if err != nil {
cacheErrors[remoteDashboardLocation] = err
}
cacheDashboards = append(cacheDashboards, filepath.Base(cacheDashboardLocation))
}
//cleanup any files in the cache that are no longer in the dashboard locations
dirEntries, err := os.ReadDir(cacheDir)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
return
}
for _, dirEntry := range dirEntries {
if !slices.Contains(cacheDashboards, dirEntry.Name()) {
logger.Debugf("Removing %v from cache", dirEntry.Name())
err = os.RemoveAll(filepath.Join(cacheDir, dirEntry.Name()))
if err != nil {
logger.Errorf("Error removing %v from cache: %v", dirEntry.Name(), err)
}
}
}
if len(cacheErrors) > 0 {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": cacheErrors})
return
} else {
err = databaseRepo.SaveUserSettings(c, userSettings)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
}
//private functions
func getCacheDir(currentUserId string) (string, error) {
// initialize the cache directory
cacheDir := filepath.Join("cache", currentUserId, "dashboard")
err := os.MkdirAll(cacheDir, 0755)
if err != nil {
return "", fmt.Errorf("error creating cache directory: %v", err)
}
return cacheDir, nil
}
func cacheCustomDashboard(logger *logrus.Entry, githubClient *github.Client, cacheDir string, remoteDashboardLocation string) (string, error) {
//- validate that the url is to a github gist, no other locations are supported
//- download the gist metadata
//- if more than 1 file found, look for a dashboard.json
//- check if the file sha exists on the file system (content-addressible file system)
//- if its not present, download it
//- if its not json, throw an error
//- if it doesnt match the dashboard config schema, throw an error.
if !strings.HasPrefix(remoteDashboardLocation, "https://gist.github.com") {
return "", fmt.Errorf("remote dashboard location is not a github gist: %v", remoteDashboardLocation)
}
logger.Infof("Processing custom dashboard from %v", remoteDashboardLocation)
gist, _, err := githubClient.Gists.Get(context.Background(), remoteDashboardLocation)
if err != nil {
return "", fmt.Errorf("error retrieving remote gist: %v", err)
}
//check if gist has more than 1 file
var dashboardJsonFile github.GistFile
if len(gist.Files) == 0 {
return "", fmt.Errorf("gist has no files: %v", remoteDashboardLocation)
}
if len(gist.Files) > 1 {
//find the dashboard.json file
if gistFile, ok := gist.Files["dashboard.json"]; ok {
dashboardJsonFile = gistFile
} else {
return "", fmt.Errorf("dashboard location gist has more than 1 file and no dashboard.json: %v", remoteDashboardLocation)
}
} else {
//only 1 file, use it
for _, gistFile := range gist.Files {
dashboardJsonFile = gistFile
if contentType := gistFile.GetType(); contentType != "application/json" {
logger.Warnf("ContentType is not detected as JSON: %v", remoteDashboardLocation)
}
}
}
//ensure that the file is valid json
var dashboardJson map[string]interface{}
err = json.Unmarshal([]byte(dashboardJsonFile.GetContent()), &dashboardJson)
if err != nil {
return "", fmt.Errorf("error unmarshalling dashboard configuration (invalid JSON?): %v", err)
}
//TODO: validate against DashboardConfigSchema
absCacheFileLocation := filepath.Join(cacheDir, gist.GetID())
//write it to filesystem
logger.Infof("Writing new dashboard configuration to filesystem: %v", remoteDashboardLocation)
//write file to cache
err = os.WriteFile(absCacheFileLocation, []byte(dashboardJsonFile.GetContent()), 0644)
if err != nil {
return "", fmt.Errorf("error writing dashboard configuration to cache: %v", err)
}
return absCacheFileLocation, nil
}
func getDashboardFromDir(dirEntries []fs.DirEntry, fsReadFile func(name string) ([]byte, error)) ([]map[string]interface{}, error) {
dashboards := []map[string]interface{}{}
for _, file := range dirEntries {
@ -105,7 +246,7 @@ func getDashboardFromEmbeddedDir(dirEntries []fs.DirEntry) ([]map[string]interfa
}
//unmarshal file into map
embeddedFile, err := dashboardFS.ReadFile("dashboard/" + file.Name())
embeddedFile, err := fsReadFile("dashboard/" + file.Name())
if err != nil {
return nil, err
}
@ -117,8 +258,6 @@ func getDashboardFromEmbeddedDir(dirEntries []fs.DirEntry) ([]map[string]interfa
}
dashboards = append(dashboards, dashboardJson)
}
return dashboards, nil
}

21
go.mod
View File

@ -13,27 +13,31 @@ require (
github.com/glebarez/sqlite v1.5.0
github.com/golang-jwt/jwt/v4 v4.4.2
github.com/golang/mock v1.6.0
github.com/google/go-github/v54 v54.0.0
github.com/google/uuid v1.3.0
github.com/iancoleman/strcase v0.2.0
github.com/lestrrat-go/jwx/v2 v2.0.11
github.com/mitchellh/mapstructure v1.5.0
github.com/philips-software/go-hsdp-api v0.81.0
github.com/samber/lo v1.35.0
github.com/sirupsen/logrus v1.9.0
github.com/spf13/viper v1.12.0
github.com/stretchr/testify v1.8.4
github.com/urfave/cli/v2 v2.11.2
golang.org/x/crypto v0.9.0
golang.org/x/crypto v0.12.0
golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17
golang.org/x/net v0.10.0
golang.org/x/net v0.14.0
gorm.io/datatypes v1.0.7
gorm.io/gorm v1.24.1
)
require (
bitbucket.org/creachadair/stringset v0.0.9 // indirect
github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 // indirect
github.com/bytedance/sonic v1.8.8 // indirect
github.com/cenkalti/backoff/v4 v4.2.1 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/cloudflare/circl v1.3.3 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
@ -49,7 +53,7 @@ require (
github.com/go-sql-driver/mysql v1.6.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/google/fhir/go v0.7.4 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
@ -69,7 +73,6 @@ require (
github.com/mattn/go-colorable v0.1.12 // indirect
github.com/mattn/go-isatty v0.0.18 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
@ -91,12 +94,12 @@ require (
github.com/ugorji/go/codec v1.2.11 // indirect
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/oauth2 v0.8.0 // indirect
golang.org/x/sys v0.8.0 // indirect
golang.org/x/term v0.8.0 // indirect
golang.org/x/text v0.9.0 // indirect
golang.org/x/oauth2 v0.11.0 // indirect
golang.org/x/sys v0.11.0 // indirect
golang.org/x/term v0.11.0 // indirect
golang.org/x/text v0.12.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/protobuf v1.30.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/ini.v1 v1.66.4 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect

37
go.sum
View File

@ -67,6 +67,8 @@ github.com/Masterminds/semver v1.4.2/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF0
github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
github.com/Masterminds/vcs v1.13.0/go.mod h1:N09YCmOQr6RLxC6UNHzuVwAdodYbbnycGHSmwVJjcKA=
github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ=
github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 h1:wPbRQzjjwFc0ih8puEVAOFGELsn1zoIIYdxvML7mDxA=
github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8/go.mod h1:I0gYDMZ6Z5GRU7l58bNFSkPTFN6Yl12dsUlAZ8xy98g=
github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc=
github.com/PuerkitoBio/purell v1.0.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/purell v1.1.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
@ -103,6 +105,7 @@ github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kB
github.com/blang/semver v3.5.0+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
github.com/buger/jsonparser v0.0.0-20200322175846-f7e751efca13/go.mod h1:tgcrVJ81GPSF0mz+0nu1Xaz0fazGPrmmJfJtxjbHhUQ=
github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.8.8 h1:Kj4AYbZSeENfyXicsYppYKO0K2YWab+i2UTSY7Ukz9Q=
github.com/bytedance/sonic v1.8.8/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
@ -122,6 +125,9 @@ github.com/chzyer/test v0.0.0-20210722231415-061457976a23/go.mod h1:Q3SI9o4m/ZMn
github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag=
github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I=
github.com/cloudflare/circl v1.3.3 h1:fE/Qz0QdIGqeWfnwq0RE0R7MI51s0M2E4Ga9kq5AEMs=
github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
@ -327,8 +333,8 @@ github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QD
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
@ -345,8 +351,10 @@ github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-github/v27 v27.0.4/go.mod h1:/0Gr8pJ55COkmv+S/yPKCczSkUPIM/LnFyubufRNIS0=
github.com/google/go-github/v54 v54.0.0 h1:OZdXwow4EAD5jEo5qg+dGFH2DpkyZvVsAehjvJuUL/c=
github.com/google/go-github/v54 v54.0.0/go.mod h1:Sw1LXWHhXRZtzJ9LI5fyJg9wbQzYvFhW8W5P2yaAQ7s=
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
@ -839,8 +847,9 @@ golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5y
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk=
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
@ -932,8 +941,9 @@ golang.org/x/net v0.0.0-20210610132358-84b48f89b13b/go.mod h1:9nx3DQGgdP8bBQD5qx
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.14.0 h1:BONx9s002vGdD9umnlX1Po8vOZmrgH34qlHcD1MfK14=
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@ -943,8 +953,8 @@ golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ
golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.8.0 h1:6dkIjl3j3LtZ/O3sTgZTMsLKSftL/B8Zgq4huOIIUu8=
golang.org/x/oauth2 v0.8.0/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE=
golang.org/x/oauth2 v0.11.0 h1:vPL4xzxBM4niKCW6g9whtaWVXTJf1U5e4aZxxFx/gbU=
golang.org/x/oauth2 v0.11.0/go.mod h1:LdF7O/8bLR/qWK9DrpXmbHLTouvRHK0SgJl0GmDBchk=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -1032,14 +1042,16 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0 h1:n5xxQn2i3PC0yLAbjTpNT85q/Kgzcr2gIoX9OrJUols=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.11.0 h1:F9tnn/DA/Im8nCwm+fX+1/eBwi4qFjRT++MhtVC4ZX0=
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
golang.org/x/text v0.0.0-20160726164857-2910a502d2bf/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@ -1051,8 +1063,9 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.12.0 h1:k+n5B8goJNdU7hSvEtMUz3d1Q6D/XW4COJSJR6fN0mc=
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@ -1229,8 +1242,8 @@ google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGj
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/DataDog/dd-trace-go.v1 v1.17.0/go.mod h1:DVp8HmDh8PuTu2Z0fVVlBsyWaC++fzwVCaGWylTe3tg=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d/go.mod h1:cuepJuh7vyXfUyUwEgHQXw849cJrilpS5NeIjOWESAw=