From 7c5eba80d710a3512409b9276d658a410a2bfa4c Mon Sep 17 00:00:00 2001 From: Jeroen Wijenbergh Date: Tue, 3 Dec 2024 16:56:42 +0100 Subject: [PATCH] All: Refactor to Discovery v3 And rename `instance` to `provider` --- cmd/geteduroam-cli/main.go | 30 ++-- cmd/geteduroam-gui/main.go | 38 ++-- cmd/geteduroam-gui/profile.go | 12 +- internal/discovery/discovery.go | 50 +++--- internal/instance/instance.go | 90 ---------- internal/instance/instance_test.go | 143 --------------- internal/{instance => provider}/profile.go | 83 ++++++--- internal/provider/provider.go | 144 +++++++++++++++ internal/provider/provider_test.go | 193 +++++++++++++++++++++ 9 files changed, 458 insertions(+), 325 deletions(-) delete mode 100644 internal/instance/instance.go delete mode 100644 internal/instance/instance_test.go rename internal/{instance => provider}/profile.go (62%) create mode 100644 internal/provider/provider.go create mode 100644 internal/provider/provider_test.go diff --git a/cmd/geteduroam-cli/main.go b/cmd/geteduroam-cli/main.go index a039c6a..e794a2f 100644 --- a/cmd/geteduroam-cli/main.go +++ b/cmd/geteduroam-cli/main.go @@ -17,10 +17,10 @@ import ( "github.com/geteduroam/linux-app/internal/discovery" "github.com/geteduroam/linux-app/internal/handler" - "github.com/geteduroam/linux-app/internal/instance" "github.com/geteduroam/linux-app/internal/log" "github.com/geteduroam/linux-app/internal/network" "github.com/geteduroam/linux-app/internal/notification" + "github.com/geteduroam/linux-app/internal/provider" "github.com/geteduroam/linux-app/internal/utils" "github.com/geteduroam/linux-app/internal/version" ) @@ -73,8 +73,8 @@ func ask(prompt string, validator func(input string) bool) string { } } -// filteredOrganizations gets the instances as filtered by the user -func filteredOrganizations(orgs *instance.Instances, q string) (f *instance.Instances) { +// filteredOrganizations gets the providers as filtered by the user +func filteredOrganizations(orgs *provider.Providers, q string) (f *provider.Providers) { for { empties := 0 x := ask(q, func(x string) bool { @@ -116,8 +116,8 @@ func validateRange(input string, n int) bool { return true } -// organization gets an organization/instance from the user -func organization(orgs *instance.Instances) *instance.Instance { +// organization gets an organization/provider from the user +func organization(orgs *provider.Providers) *provider.Provider { _, h, err := term.GetSize(0) if err != nil { slog.Warn("Could not get height") @@ -152,7 +152,7 @@ func organization(orgs *instance.Instances) *instance.Instance { } // profile gets a profile for a list of profiles by asking the user one if there are multiple -func profile(profiles []instance.Profile) *instance.Profile { +func profile(profiles []provider.Profile) *provider.Profile { // Only one profile, return it immediately if len(profiles) == 1 { return &profiles[0] @@ -312,7 +312,7 @@ func file(metadata []byte) (*time.Time, error) { } // direct does the handling for the direct flow -func direct(p *instance.Profile) { +func direct(p *provider.Profile) { config, err := p.EAPDirect() if err != nil { slog.Error("Could not obtain eap config", "error", err) @@ -330,7 +330,7 @@ func direct(p *instance.Profile) { } // redirect does the handling for the redirect flow -func redirect(p *instance.Profile) { +func redirect(p *provider.Profile) { r, err := p.RedirectURI() if err != nil { slog.Error("Failed to complete the flow, no redirect URI is available") @@ -347,7 +347,7 @@ func redirect(p *instance.Profile) { } // oauth does the handling for the OAuth flow -func oauth(p *instance.Profile) *time.Time { +func oauth(p *provider.Profile) *time.Time { config, err := p.EAPOAuth(context.Background(), func(url string) { fmt.Println("Your browser has been opened to authorize the client") fmt.Println("Or copy and paste the following url:", url) @@ -384,10 +384,10 @@ func doLocal(filename string) *time.Time { func doDiscovery() *time.Time { c := discovery.NewCache() - i, err := c.Instances() + i, err := c.Providers() if err != nil { - slog.Error("Failed to get instances from discovery", "error", err) - fmt.Printf("Failed to get instances from discovery %v\n", err) + slog.Error("Failed to get providers from discovery", "error", err) + fmt.Printf("Failed to get providers from discovery %v\n", err) os.Exit(1) } @@ -397,11 +397,11 @@ func doDiscovery() *time.Time { // TODO: This switch statement should probably be moved to the profile code // By providing an "EAP" method on profile switch p.Flow() { - case instance.DirectFlow: + case provider.DirectFlow: direct(p) - case instance.RedirectFlow: + case provider.RedirectFlow: redirect(p) - case instance.OAuthFlow: + case provider.OAuthFlow: return oauth(p) } return nil diff --git a/cmd/geteduroam-gui/main.go b/cmd/geteduroam-gui/main.go index d6ab63f..5b5ac07 100644 --- a/cmd/geteduroam-gui/main.go +++ b/cmd/geteduroam-gui/main.go @@ -21,31 +21,31 @@ import ( "github.com/geteduroam/linux-app/internal/discovery" "github.com/geteduroam/linux-app/internal/handler" - "github.com/geteduroam/linux-app/internal/instance" "github.com/geteduroam/linux-app/internal/log" "github.com/geteduroam/linux-app/internal/network" + "github.com/geteduroam/linux-app/internal/provider" "github.com/geteduroam/linux-app/internal/version" ) type serverList struct { sync.Mutex store *gtk.StringList - instances instance.Instances + providers provider.Providers list *SelectList } -func (s *serverList) get(idx int) (*instance.Instance, error) { - if idx < 0 || idx > len(s.instances) { +func (s *serverList) get(idx int) (*provider.Provider, error) { + if idx < 0 || idx > len(s.providers) { return nil, errors.New("index out of range") } - return &s.instances[idx], nil + return &s.providers[idx], nil } func (s *serverList) Fill() { s.Lock() defer s.Unlock() - for idx, inst := range s.instances { - s.list.Add(idx, inst.Name) + for idx, inst := range s.providers { + s.list.Add(idx, inst.Name.Get()) } } @@ -93,7 +93,7 @@ func (m *mainState) file(metadata []byte) (*time.Time, error) { return h.Configure(metadata) } -func (m *mainState) direct(p instance.Profile) error { +func (m *mainState) direct(p provider.Profile) error { config, err := p.EAPDirect() if err != nil { return err @@ -114,7 +114,7 @@ func (m *mainState) local(path string) (*time.Time, error) { return v, nil } -func (m *mainState) oauth(ctx context.Context, p instance.Profile) (*time.Time, error) { +func (m *mainState) oauth(ctx context.Context, p provider.Profile) (*time.Time, error) { ctx, cancel := context.WithCancel(ctx) defer cancel() config, err := p.EAPOAuth(ctx, func(url string) { @@ -135,31 +135,31 @@ func (m *mainState) oauth(ctx context.Context, p instance.Profile) (*time.Time, return m.file(config) } -func (m *mainState) rowActivated(sel instance.Instance) { +func (m *mainState) rowActivated(sel provider.Provider) { var page gtk.Box m.builder.GetObject("searchPage").Cast(&page) defer page.Unref() l := NewLoadingPage(m.builder, m.stack, "Loading organization details...", nil) l.Initialize() ctx := context.Background() - chosen := func(p instance.Profile) (err error) { + chosen := func(p provider.Profile) (err error) { defer func() { err = ensureContextError(ctx, err) }() var valid *time.Time var isredirect bool switch p.Flow() { - case instance.DirectFlow: + case provider.DirectFlow: err = m.direct(p) if err != nil { return err } - case instance.OAuthFlow: + case provider.OAuthFlow: valid, err = m.oauth(ctx, p) if err != nil { return err } - case instance.RedirectFlow: + case provider.RedirectFlow: isredirect = true url, err := p.RedirectURI() if err != nil { @@ -177,7 +177,7 @@ func (m *mainState) rowActivated(sel instance.Instance) { }) return nil } - cb := func(p instance.Profile) { + cb := func(p provider.Profile) { err := chosen(p) if err != nil { l.Hide() @@ -200,12 +200,12 @@ func (m *mainState) initList() { defer list.Unref() cache := discovery.NewCache() - inst, err := cache.Instances() + inst, err := cache.Providers() if err != nil { m.ShowError(err) return } - m.servers.instances = *inst + m.servers.providers = *inst var search gtk.SearchEntry m.builder.GetObject("searchBox").Cast(&search) @@ -222,11 +222,11 @@ func (m *mainState) initList() { } sorter := func(a, b string) int { - return instance.SortNames(a, b, search.GetText()) + return provider.SortNames(a, b, search.GetText()) } m.servers.list = NewSelectList(m.scroll, &list, activated, sorter).WithFiltering(func(a string) bool { - return instance.FilterSingle(a, search.GetText()) + return provider.FilterSingle(a, search.GetText()) }) // Fill the servers in the select list diff --git a/cmd/geteduroam-gui/profile.go b/cmd/geteduroam-gui/profile.go index c8f0751..bf34cc8 100644 --- a/cmd/geteduroam-gui/profile.go +++ b/cmd/geteduroam-gui/profile.go @@ -3,7 +3,7 @@ package main import ( "golang.org/x/exp/slog" - "github.com/geteduroam/linux-app/internal/instance" + "github.com/geteduroam/linux-app/internal/provider" "github.com/jwijenbergh/puregotk/v4/adw" "github.com/jwijenbergh/puregotk/v4/gtk" ) @@ -11,12 +11,12 @@ import ( type ProfileState struct { builder *gtk.Builder stack *adw.ViewStack - profiles []instance.Profile - success func(instance.Profile) + profiles []provider.Profile + success func(provider.Profile) sl *SelectList } -func NewProfileState(builder *gtk.Builder, stack *adw.ViewStack, profiles []instance.Profile, success func(instance.Profile)) *ProfileState { +func NewProfileState(builder *gtk.Builder, stack *adw.ViewStack, profiles []provider.Profile, success func(provider.Profile)) *ProfileState { return &ProfileState{ builder: builder, stack: stack, @@ -55,7 +55,7 @@ func (p *ProfileState) Initialize() { sorter := func(a, b string) int { // Here we have no search query - return instance.SortNames(a, b, "") + return provider.SortNames(a, b, "") } activated := func(idx int) { go p.success(p.profiles[idx]) @@ -65,7 +65,7 @@ func (p *ProfileState) Initialize() { p.sl = NewSelectList(&scroll, &list, activated, sorter) for idx, prof := range p.profiles { - p.sl.Add(idx, prof.Name) + p.sl.Add(idx, prof.Name.Get()) } p.sl.Setup() diff --git a/internal/discovery/discovery.go b/internal/discovery/discovery.go index ec7cb92..84a77c8 100644 --- a/internal/discovery/discovery.go +++ b/internal/discovery/discovery.go @@ -1,4 +1,4 @@ -// package discovery contains methods to parse the discovery format from https://discovery.eduroam.app/v1/discovery.json into instances +// package discovery contains methods to parse the discovery format from https://discovery.eduroam.app/v3/discovery.json into providers package discovery import ( @@ -10,15 +10,18 @@ import ( "golang.org/x/exp/slog" - "github.com/geteduroam/linux-app/internal/instance" + "github.com/geteduroam/linux-app/internal/provider" ) // Discovery is the main structure that is used for unmarshalling the JSON type Discovery struct { - Instances instance.Instances `json:"instances"` + Value Value `json:"http://letswifi.app/discovery#v3"` +} + +type Value struct { + Providers provider.Providers `json:"providers"` // See: https://github.com/geteduroam/windows-app/blob/22cd90f36031907c7174fbdc678edafaa627ce49/CHANGELOG.md#changed - Seq int `json:"seq"` - Version int `json:"version"` + Seq int `json:"seq"` } // Cache is the cached discovery list @@ -46,15 +49,15 @@ func (c *Cache) ToUpdate() bool { return n.After(u) } -// Instances gets the instances either from the cache or from scratch -func (c *Cache) Instances() (*instance.Instances, error) { +// Providers gets the providers either from the cache or from scratch +func (c *Cache) Providers() (*provider.Providers, error) { if !c.ToUpdate() { - return &c.Cached.Instances, nil + return &c.Cached.Value.Providers, nil } - req, err := http.NewRequest("GET", "https://discovery.eduroam.app/v1/discovery.json", nil) + req, err := http.NewRequest("GET", "https://discovery.eduroam.app/v3/discovery.json", nil) if err != nil { - return &c.Cached.Instances, err + return &c.Cached.Value.Providers, err } // Do request @@ -62,46 +65,33 @@ func (c *Cache) Instances() (*instance.Instances, error) { res, err := client.Do(req) if err != nil { slog.Debug("Error requesting discovery.json", "error", err) - return &c.Cached.Instances, err + return &c.Cached.Value.Providers, err } defer res.Body.Close() body, err := io.ReadAll(res.Body) if err != nil { slog.Debug("Error reading discovery.json response", "error", err) - return &c.Cached.Instances, err + return &c.Cached.Value.Providers, err } if res.StatusCode < 200 || res.StatusCode > 299 { - return &c.Cached.Instances, fmt.Errorf("status code is not 2xx for discovery. Status code: %v, body: %v", res.StatusCode, string(body)) + return &c.Cached.Value.Providers, fmt.Errorf("status code is not 2xx for discovery. Status code: %v, body: %v", res.StatusCode, string(body)) } var d *Discovery err = json.Unmarshal(body, &d) if err != nil { slog.Debug("Error loading discovery.json", "error", err) - return &c.Cached.Instances, err + return &c.Cached.Value.Providers, err } - d.Instances = append(d.Instances, instance.Instance{ - Name: "LetsWifi development banaan", - Profiles: []instance.Profile{ - { - AuthorizationEndpoint: "http://0.0.0.0:8080/oauth/authorize/", - Default: true, - EapConfigEndpoint: "http://0.0.0.0:8080/api/eap-config/", - OAuth: true, - TokenEndpoint: "http://0.0.0.0:8080/oauth/token/", - }, - }, - }) - // Do not accept older versions // This happens if the cached version is higher - if c.Cached.Seq > d.Seq { - return &c.Cached.Instances, fmt.Errorf("cached seq is higher") + if c.Cached.Value.Seq > d.Value.Seq { + return &c.Cached.Value.Providers, fmt.Errorf("cached seq is higher") } c.Cached = *d c.LastUpdate = time.Now() - return &d.Instances, nil + return &d.Value.Providers, nil } diff --git a/internal/instance/instance.go b/internal/instance/instance.go deleted file mode 100644 index c90f644..0000000 --- a/internal/instance/instance.go +++ /dev/null @@ -1,90 +0,0 @@ -package instance - -import ( - "fmt" - "regexp" - "sort" - "strings" - - "github.com/geteduroam/linux-app/internal/utils" -) - -type geo struct { - Lat float32 `json:"lat"` - Long float32 `json:"long"` -} - -type Instance struct { - CatIDP int `json:"cat_idp"` - Country string `json:"country"` - Geo []geo `json:"geo"` - ID string `json:"id"` - Name string `json:"name"` - Profiles []Profile `json:"profiles"` -} - -type Instances []Instance - -func SortNames(a string, b string, search string) int { - la := strings.ToLower(a) - lb := strings.ToLower(b) - bd := strings.Compare(la, lb) - // compute the base difference which is based on alphabetical order - // if no search is defined return the base difference - if search == "" { - return bd - } - lower := strings.ToLower(search) - escaped := regexp.QuoteMeta(lower) - match := regexp.MustCompile(fmt.Sprintf("(^|[\\P{L}])%s[\\P{L}]", escaped)) - mi := match.MatchString(la) - mj := match.MatchString(lb) - if mi == mj { - // tiebreak on alphabetical order - return bd - } else if mi { - return -1 - } - return 1 -} - -type ByName struct { - Instances Instances - Search string -} - -func (s ByName) Len() int { return len(s.Instances) } -func (s ByName) Swap(i, j int) { s.Instances[i], s.Instances[j] = s.Instances[j], s.Instances[i] } -func (s ByName) Less(i, j int) bool { - diff := SortNames(s.Instances[i].Name, s.Instances[j].Name, s.Search) - // if i is less than j, diff returns less than 0 - return diff < 0 -} - -func FilterSingle(name string, search string) bool { - l1, err1 := utils.RemoveDiacritics(strings.ToLower(name)) - l2, err2 := utils.RemoveDiacritics(strings.ToLower(search)) - if err1 != nil || err2 != nil { - return false - } - if !strings.Contains(l1, l2) { - return false - } - return true -} - -// FilterSort filters and sorts a list of instances -// The sorting is done in reverse as this is used in the CLI where the most relevant instances should be shown at the bottom -func (i *Instances) FilterSort(search string) *Instances { - x := ByName{ - Instances: Instances{}, - Search: search, - } - for _, i := range *i { - if FilterSingle(i.Name, search) { - x.Instances = append(x.Instances, i) - } - } - sort.Sort(sort.Reverse(ByName(x))) - return &x.Instances -} diff --git a/internal/instance/instance_test.go b/internal/instance/instance_test.go deleted file mode 100644 index 14e4f54..0000000 --- a/internal/instance/instance_test.go +++ /dev/null @@ -1,143 +0,0 @@ -package instance - -import ( - "testing" - - "github.com/geteduroam/linux-app/internal/utils" -) - -func TestFilterSort(t *testing.T) { - i := Instances{ - { - Name: "Instance One", - }, - { - // Diacritics - Name: "Instånce Twö", - }, - } - - cases := []struct { - input string - length int - want string - }{ - { - // Normal test - input: "One", - length: 1, - want: "Instance One", - }, - { - // Filter case-insensitive - input: "one", - length: 1, - want: "Instance One", - }, - { - // Filter case-insensitive diacriticless - input: "two", - length: 1, - want: "Instånce Twö", - }, - { - // Filter all case-insensitive diacriticless - input: "instance", - length: 2, - want: "Instånce Twö", - }, - } - - for _, c := range cases { - result := i.FilterSort(c.input) - length := len(*result) - name := (*result)[0].Name - if name != c.want || length != c.length { - t.Fatalf("Result: %s, %d, Want: %s, %d", name, length, c.want, c.length) - } - } -} - -func TestFlow(t *testing.T) { - p := Profile{ - AuthorizationEndpoint: "https://instance1.geteduroam.nl/oauth/authorize/", - Default: true, - EapConfigEndpoint: "https://instance1.geteduroam.nl/api/eap-config/", - ID: "letswifi_cat_0001", - Name: "geteduroam", - OAuth: true, - TokenEndpoint: "https://instance1.geteduroam.nl/oauth/token/", - Redirect: "https://instance1.geteduroam.nl/", - } - - var flow FlowCode - flow = p.Flow() - if flow != RedirectFlow { - t.Fatalf("Flow should be RedirectFlow") - } - - p.Redirect = "" - flow = p.Flow() - if flow != OAuthFlow { - t.Fatalf("Flow should be OAuthFlow") - } - - p.OAuth = false - flow = p.Flow() - if flow != DirectFlow { - t.Fatalf("Flow should be DirectFlow") - } -} - -func TestRedirectURI(t *testing.T) { - p := Profile{ - AuthorizationEndpoint: "https://instance1.geteduroam.nl/oauth/authorize/", - Default: true, - EapConfigEndpoint: "https://instance1.geteduroam.nl/api/eap-config/", - ID: "letswifi_cat_0001", - Name: "geteduroam", - OAuth: true, - TokenEndpoint: "https://instance1.geteduroam.nl/oauth/token/", - Redirect: "", - } - - cases := []struct { - input string - want string - e string - }{ - { - // Normal test - input: "https://instance1.geteduroam.nl/", - want: "https://instance1.geteduroam.nl/", - e: "", - }, - { - // No Redirect - input: "", - want: "", - e: "no redirect found", - }, - { - // Enforce Test - input: "http://instance1.geteduroam.nl/", - want: "https://instance1.geteduroam.nl/", - e: "", - }, - { - // No URL - input: "foobar", - want: "https://foobar", - e: "", - }, - } - - for _, c := range cases { - p.Redirect = c.input - r, e := p.RedirectURI() - es := utils.ErrorString(e) - if r != c.want || es != c.e { - t.Fatalf("Result: %s, %s Want: %s, %s", r, es, c.want, c.e) - } - } -} diff --git a/internal/instance/profile.go b/internal/provider/profile.go similarity index 62% rename from internal/instance/profile.go rename to internal/provider/profile.go index ac97a0c..99c7530 100644 --- a/internal/instance/profile.go +++ b/internal/provider/profile.go @@ -1,7 +1,8 @@ -package instance +package provider import ( "context" + "encoding/json" "errors" "fmt" "io" @@ -15,14 +16,13 @@ import ( // Profile is the profile from discovery type Profile struct { - AuthorizationEndpoint string `json:"authorization_endpoint"` - Default bool `json:"default"` - EapConfigEndpoint string `json:"eapconfig_endpoint"` - ID string `json:"id"` - Name string `json:"name"` - OAuth bool `json:"oauth"` - TokenEndpoint string `json:"token_endpoint"` - Redirect string `json:"redirect"` + ID string `json:"id"` + EapConfigEndpoint string `json:"eapconfig_endpoint"` + MobileConfigEndpoint string `json:"mobileconfig_endpoint"` + LetsWifiEndpoint string `json:"letswifi_endpoint"` + WebviewEndpoint string `json:"webview_endpoint"` + Name LocalizedStrings `json:"name"` + Type string `json:"type"` } // FlowCode is the type of flow that we will use to get the EAP config @@ -40,17 +40,14 @@ const ( // Flow gets the flow we need to go through to get the EAP config // See: https://github.com/geteduroam/cattenbak/blob/481e243f22b40e1d8d48ecac2b85705b8cb48494/cattenbak.py#L68 func (p *Profile) Flow() FlowCode { - // A Redirect entry is present - // This means that we need to follow the URI in the redirect flow - if p.Redirect != "" { + switch p.Type { + case "webview": return RedirectFlow - } - // OAuth is present, we need to get the EAP through some OAuth flow - if p.OAuth { + case "eap-config": + return DirectFlow + default: return OAuthFlow } - // Get the config directly - return DirectFlow } // RedirectURI gets the redirect URI from the profile @@ -58,10 +55,10 @@ func (p *Profile) Flow() FlowCode { // - Checking if the redirect URI is a URL // - Setting the scheme to HTTPS func (p *Profile) RedirectURI() (string, error) { - if p.Redirect == "" { + if p.WebviewEndpoint == "" { return "", errors.New("no redirect found") } - u, err := url.Parse(p.Redirect) + u, err := url.Parse(p.WebviewEndpoint) if err != nil { return "", err } @@ -103,14 +100,56 @@ func (p *Profile) EAPDirect() ([]byte, error) { return readResponse(res) } +type letsWifiEndpoints struct { + Href string `json:"href"` + API struct { + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + EapConfigEndpoint string `json:"eapconfig_endpoint"` + MobileConfigEndpoint string `json:"mobileconfig_endpoint"` + } `json:"http://letswifi.app/api#v2"` +} + +func (p *Profile) getLetsWifiEndpoints() (*letsWifiEndpoints, error) { + client := http.Client{Timeout: 10 * time.Second} + if p.LetsWifiEndpoint == "" { + return nil, errors.New("no Let's Wifi endpoint found") + } + req, err := http.NewRequest("GET", p.LetsWifiEndpoint, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + res, err := client.Do(req) + if err != nil { + return nil, err + } + b, err := readResponse(res) + if err != nil { + return nil, err + } + + var ep letsWifiEndpoints + err = json.Unmarshal(b, &ep) + if err != nil { + return nil, err + } + return &ep, nil +} + // EAPOAuth gets the EAP metadata using OAuth func (p *Profile) EAPOAuth(ctx context.Context, auth func(authURL string)) ([]byte, error) { + ep, err := p.getLetsWifiEndpoints() + if err != nil { + return nil, err + } + o := eduoauth.OAuth{ ClientID: "app.geteduroam.sh", EndpointFunc: func(context.Context) (*eduoauth.EndpointResponse, error) { return &eduoauth.EndpointResponse{ - AuthorizationURL: p.AuthorizationEndpoint, - TokenURL: p.TokenEndpoint, + AuthorizationURL: ep.API.AuthorizationEndpoint, + TokenURL: ep.API.TokenEndpoint, }, nil }, RedirectPath: "/", @@ -133,7 +172,7 @@ func (p *Profile) EAPOAuth(ctx context.Context, auth func(authURL string)) ([]by } c := o.NewHTTPClient() - req, err := http.NewRequestWithContext(ctx, "POST", p.EapConfigEndpoint, nil) + req, err := http.NewRequestWithContext(ctx, "POST", ep.API.EapConfigEndpoint, nil) if err != nil { return nil, err } diff --git a/internal/provider/provider.go b/internal/provider/provider.go new file mode 100644 index 0000000..2663cd6 --- /dev/null +++ b/internal/provider/provider.go @@ -0,0 +1,144 @@ +package provider + +import ( + "fmt" + "os" + "regexp" + "sort" + "strings" + + "github.com/geteduroam/linux-app/internal/utils" + "golang.org/x/text/language" +) + +type LocalizedString struct { + Display string `json:"display"` + Lang string `json:"lang"` +} + +type LocalizedStrings []LocalizedString + +var systemLanguage = language.English + +func setSystemLanguage() { + lang := os.Getenv("LANG") + if lang == "" { + lang = os.Getenv("LC_ALL") + } + first := strings.Split(lang, ".")[0] + tag, err := language.Parse(first) + if err != nil { + // TODO: log invalid language + return + } + systemLanguage = tag +} + +func (ls LocalizedStrings) Get() string { + // first get the non-empty values + var disp string + var conf language.Confidence + m := language.NewMatcher([]language.Tag{systemLanguage}) + for _, val := range ls { + // no display yet + if disp == "" { + disp = val.Display + // we don't continue here as we still need to store the confidence + } + if val.Lang == "" { + continue + } + t, err := language.Parse(val.Lang) + // tag is invalid, just continue with the next option + if err != nil { + fmt.Printf("invalid language tag: %v, err: %v\n", val.Lang, err) + continue + } + + // the confidence that this matches + // is higher than the current confidence + _, _, got := m.Match(t) + if got > conf { + disp = val.Display + conf = got + } + } + return disp +} + +type Provider struct { + ID string `json:"id"` + Country string `json:"country"` + Name LocalizedStrings `json:"name"` + Profiles []Profile `json:"profiles"` +} + +type Providers []Provider + +func SortNames(a string, b string, search string) int { + la := strings.ToLower(a) + lb := strings.ToLower(b) + bd := strings.Compare(la, lb) + // compute the base difference which is based on alphabetical order + // if no search is defined return the base difference + if search == "" { + return bd + } + lower := strings.ToLower(search) + escaped := regexp.QuoteMeta(lower) + match := regexp.MustCompile(fmt.Sprintf("(^|[\\P{L}])%s[\\P{L}]", escaped)) + mi := match.MatchString(la) + mj := match.MatchString(lb) + if mi == mj { + // tiebreak on alphabetical order + return bd + } else if mi { + return -1 + } + return 1 +} + +type ByName struct { + Providers Providers + Search string +} + +func (s ByName) Len() int { return len(s.Providers) } +func (s ByName) Swap(i, j int) { s.Providers[i], s.Providers[j] = s.Providers[j], s.Providers[i] } +func (s ByName) Less(i, j int) bool { + diff := SortNames(s.Providers[i].Name.Get(), s.Providers[j].Name.Get(), s.Search) + // if i is less than j, diff returns less than 0 + return diff < 0 +} + +func FilterSingle(name string, search string) bool { + l1, err1 := utils.RemoveDiacritics(strings.ToLower(name)) + l2, err2 := utils.RemoveDiacritics(strings.ToLower(search)) + if err1 != nil || err2 != nil { + return false + } + if !strings.Contains(l1, l2) { + return false + } + return true +} + +// FilterSort filters and sorts a list of providers +// The sorting is done in reverse as this is used in the CLI where the most relevant providers should be shown at the bottom +func (i *Providers) FilterSort(search string) *Providers { + x := ByName{ + Providers: Providers{}, + Search: search, + } + for _, i := range *i { + if FilterSingle(i.Name.Get(), search) { + x.Providers = append(x.Providers, i) + } + } + sort.Sort(sort.Reverse(ByName(x))) + return &x.Providers +} + +func init() { + setSystemLanguage() +} diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go new file mode 100644 index 0000000..2bcbd1e --- /dev/null +++ b/internal/provider/provider_test.go @@ -0,0 +1,193 @@ +package provider + +import ( + "testing" + + "github.com/geteduroam/linux-app/internal/utils" + "golang.org/x/text/language" +) + +func TestLocalizedStrings(t *testing.T) { + cases := []struct { + input LocalizedStrings + lang language.Tag + want string + }{ + { + input: LocalizedStrings{ + {Display: "disp_en", Lang: "en"}, + {Display: "disp_nl", Lang: "nl"}, + }, + lang: language.English, + want: "disp_en", + }, + { + input: LocalizedStrings{ + {Display: "disp_en", Lang: "en"}, + {Display: "disp_nl", Lang: "nl"}, + }, + lang: language.Dutch, + want: "disp_nl", + }, + { + input: LocalizedStrings{ + {Display: "disp_en", Lang: "en"}, + }, + lang: language.German, + want: "disp_en", + }, + { + input: LocalizedStrings{ + {Display: "disp_en", Lang: "en"}, + {Display: "disp_gb", Lang: "en_GB"}, + }, + lang: language.BritishEnglish, + want: "disp_gb", + }, + { + input: LocalizedStrings{ + {Display: "disp_en", Lang: "en"}, + {Display: "disp_gb", Lang: "en_GB"}, + }, + lang: language.English, + want: "disp_en", + }, + } + + for _, c := range cases { + systemLanguage = c.lang + got := c.input.Get() + if got != c.want { + t.Fatalf("Got: %s, Not equal to Want: %s", got, c.want) + } + } +} + +func TestFilterSort(t *testing.T) { + i := Providers{ + { + Name: LocalizedStrings{{Display: "Provider One"}}, + }, + { + // Diacritics + Name: LocalizedStrings{{Display: "Provider Twö"}}, + }, + } + + cases := []struct { + input string + length int + want string + }{ + { + // Normal test + input: "One", + length: 1, + want: "Provider One", + }, + { + // Filter case-insensitive + input: "one", + length: 1, + want: "Provider One", + }, + { + // Filter case-insensitive diacriticless + input: "two", + length: 1, + want: "Provider Twö", + }, + { + // Filter all case-insensitive diacriticless + input: "provider", + length: 2, + want: "Provider Twö", + }, + } + + for _, c := range cases { + result := i.FilterSort(c.input) + length := len(*result) + name := (*result)[0].Name + if name.Get() != c.want || length != c.length { + t.Fatalf("Result: %s, %d, Want: %s, %d", name, length, c.want, c.length) + } + } +} + +func TestFlow(t *testing.T) { + p := Profile{ + EapConfigEndpoint: "https://provider1.geteduroam.nl/api/eap-config/", + MobileConfigEndpoint: "https://provider1.geteduroam.nl/api/eap-config/?format=mobileconfig", + WebviewEndpoint: "https://provider1.geteduroam.nl/", + ID: "letswifi_cat_0001", + Name: LocalizedStrings{{Display: "geteduroam"}}, + Type: "webview", + } + + var flow FlowCode + flow = p.Flow() + if flow != RedirectFlow { + t.Fatalf("Flow should be RedirectFlow") + } + + p.Type = "" + flow = p.Flow() + if flow != OAuthFlow { + t.Fatalf("Flow should be OAuthFlow") + } + + p.Type = "eap-config" + flow = p.Flow() + if flow != DirectFlow { + t.Fatalf("Flow should be DirectFlow") + } +} + +func TestRedirectURI(t *testing.T) { + p := Profile{ + ID: "letswifi_cat_0001", + Name: LocalizedStrings{{Display: "geteduroam"}}, + WebviewEndpoint: "", + } + + cases := []struct { + input string + want string + e string + }{ + { + // Normal test + input: "https://provider1.geteduroam.nl/", + want: "https://provider1.geteduroam.nl/", + e: "", + }, + { + // No Redirect + input: "", + want: "", + e: "no redirect found", + }, + { + // Enforce Test + input: "http://provider1.geteduroam.nl/", + want: "https://provider1.geteduroam.nl/", + e: "", + }, + { + // No URL + input: "foobar", + want: "https://foobar", + e: "", + }, + } + + for _, c := range cases { + p.WebviewEndpoint = c.input + r, e := p.RedirectURI() + es := utils.ErrorString(e) + if r != c.want || es != c.e { + t.Fatalf("Result: %s, %s Want: %s, %s", r, es, c.want, c.e) + } + } +}