diff --git a/cmd/account/account_company.go b/cmd/account/account_company.go deleted file mode 100644 index a7f60d54..00000000 --- a/cmd/account/account_company.go +++ /dev/null @@ -1,14 +0,0 @@ -package account - -import ( - "github.com/spf13/cobra" -) - -var accountCompanyRootCmd = &cobra.Command{ - Use: "company", - Short: "Manage your Shopware company", -} - -func init() { - accountRootCmd.AddCommand(accountCompanyRootCmd) -} diff --git a/cmd/account/account_company_list.go b/cmd/account/account_company_list.go deleted file mode 100644 index cdb8211b..00000000 --- a/cmd/account/account_company_list.go +++ /dev/null @@ -1,37 +0,0 @@ -package account - -import ( - "os" - "strconv" - "strings" - - "github.com/spf13/cobra" - - "github.com/shopware/shopware-cli/internal/table" -) - -var accountCompanyListCmd = &cobra.Command{ - Use: "list", - Short: "Lists all available company for your Account", - Aliases: []string{"ls"}, - Long: ``, - Run: func(_ *cobra.Command, _ []string) { - table := table.NewWriter(os.Stdout) - table.Header([]string{"ID", "Name", "Customer ID", "Roles"}) - - for _, membership := range services.AccountClient.GetMemberships() { - _ = table.Append([]string{ - strconv.FormatInt(int64(membership.Company.Id), 10), - membership.Company.Name, - membership.Company.CustomerNumber, - strings.Join(membership.GetRoles(), ", "), - }) - } - - _ = table.Render() - }, -} - -func init() { - accountCompanyRootCmd.AddCommand(accountCompanyListCmd) -} diff --git a/cmd/account/account_company_use.go b/cmd/account/account_company_use.go deleted file mode 100644 index cb602be3..00000000 --- a/cmd/account/account_company_use.go +++ /dev/null @@ -1,50 +0,0 @@ -package account - -import ( - "fmt" - "strconv" - - "github.com/spf13/cobra" - - accountApi "github.com/shopware/shopware-cli/internal/account-api" - "github.com/shopware/shopware-cli/logging" -) - -var accountCompanyUseCmd = &cobra.Command{ - Use: "use [companyId]", - Short: "Use another company for your Account", - Args: cobra.MinimumNArgs(1), - Long: ``, - RunE: func(cmd *cobra.Command, args []string) error { - companyID, err := strconv.Atoi(args[0]) - if err != nil { - return err - } - - for _, membership := range services.AccountClient.GetMemberships() { - if membership.Company.Id == companyID { - if err := services.Conf.SetAccountCompanyId(companyID); err != nil { - return err - } - - if err := services.Conf.Save(); err != nil { - return err - } - - err = accountApi.InvalidateTokenCache() - if err != nil { - return fmt.Errorf("cannot invalidate token cache: %w", err) - } - - logging.FromContext(cmd.Context()).Infof("Successfully changed your company to %s (%s)", membership.Company.Name, membership.Company.CustomerNumber) - return nil - } - } - - return fmt.Errorf("company with ID \"%d\" not found", companyID) - }, -} - -func init() { - accountCompanyRootCmd.AddCommand(accountCompanyUseCmd) -} diff --git a/cmd/account/account_login.go b/cmd/account/account_login.go index d965c9ac..6ba017ab 100644 --- a/cmd/account/account_login.go +++ b/cmd/account/account_login.go @@ -1,14 +1,11 @@ package account import ( - "context" "fmt" - "github.com/charmbracelet/huh" "github.com/spf13/cobra" accountApi "github.com/shopware/shopware-cli/internal/account-api" - "github.com/shopware/shopware-cli/internal/system" "github.com/shopware/shopware-cli/logging" ) @@ -17,63 +14,14 @@ var loginCmd = &cobra.Command{ Short: "Login into your Shopware Account", Long: "", RunE: func(cmd *cobra.Command, _ []string) error { - email := services.Conf.GetAccountEmail() - password := services.Conf.GetAccountPassword() - newCredentials := false - - if len(email) == 0 || len(password) == 0 { - if !system.IsInteractionEnabled(cmd.Context()) { - return fmt.Errorf("credentials missing and interaction is disabled") - } - - var err error - email, password, err = askUserForEmailAndPassword() - if err != nil { - return err - } - - newCredentials = true - - if err := services.Conf.SetAccountEmail(email); err != nil { - return err - } - if err := services.Conf.SetAccountPassword(password); err != nil { - return err - } - } else { - logging.FromContext(cmd.Context()).Infof("Using existing credentials. Use account:logout to logout") - } - - client, err := accountApi.NewApi(cmd.Context(), accountApi.LoginRequest{Email: email, Password: password}) - if err != nil { - return fmt.Errorf("login failed with error: %w", err) - } - - if companyId := services.Conf.GetAccountCompanyId(); companyId > 0 { - err = changeAPIMembership(cmd.Context(), client, companyId) - if err != nil { - return fmt.Errorf("cannot change company member ship: %w", err) - } - } - - if newCredentials { - err := services.Conf.Save() - if err != nil { - return fmt.Errorf("cannot save config: %w", err) - } - } - - profile, err := client.GetMyProfile(cmd.Context()) + client, err := accountApi.NewApi(cmd.Context(), nil) if err != nil { return err } - logging.FromContext(cmd.Context()).Infof( - "Hey %s %s. You are now authenticated on company %s and can use all account commands", - profile.PersonalData.FirstName, - profile.PersonalData.LastName, - client.GetActiveMembership().Company.Name, - ) + fmt.Println(client.Token) + + logging.FromContext(cmd.Context()).Infof("Loggedin as %s", client.Token.Extra("email")) return nil }, @@ -82,51 +30,3 @@ var loginCmd = &cobra.Command{ func init() { accountRootCmd.AddCommand(loginCmd) } - -func askUserForEmailAndPassword() (string, string, error) { - var email, password string - - form := huh.NewForm( - huh.NewGroup( - huh.NewInput(). - Title("Email"). - Validate(emptyValidator). - Value(&email), - huh.NewInput(). - Title("Password"). - EchoMode(huh.EchoModePassword). - Validate(emptyValidator). - Value(&password), - ), - ) - - if err := form.Run(); err != nil { - return "", "", fmt.Errorf("prompt failed %w", err) - } - - return email, password, nil -} - -func emptyValidator(s string) error { - if len(s) == 0 { - return fmt.Errorf("this cannot be empty") - } - - return nil -} - -func changeAPIMembership(ctx context.Context, client *accountApi.Client, companyID int) error { - if companyID == 0 || client.GetActiveCompanyID() == companyID { - logging.FromContext(ctx).Debugf("Client is on correct membership skip") - return nil - } - - for _, membership := range client.GetMemberships() { - if membership.Company.Id == companyID { - logging.FromContext(ctx).Debugf("Changing member ship from %s (%d) to %s (%d)", client.ActiveMembership.Company.Name, client.ActiveMembership.Company.Id, membership.Company.Name, membership.Company.Id) - return client.ChangeActiveMembership(ctx, membership) - } - } - - return fmt.Errorf("could not find configured company with id %d", companyID) -} diff --git a/cmd/account/account_logout.go b/cmd/account/account_logout.go index 545490dc..d3c44890 100644 --- a/cmd/account/account_logout.go +++ b/cmd/account/account_logout.go @@ -20,8 +20,6 @@ var logoutCmd = &cobra.Command{ } _ = services.Conf.SetAccountCompanyId(0) - _ = services.Conf.SetAccountEmail("") - _ = services.Conf.SetAccountPassword("") if err := services.Conf.Save(); err != nil { return fmt.Errorf("cannot write config: %w", err) diff --git a/cmd/account/account_producer_extension_upload.go b/cmd/account/account_producer_extension_upload.go index 12185c32..db1aa42a 100644 --- a/cmd/account/account_producer_extension_upload.go +++ b/cmd/account/account_producer_extension_upload.go @@ -53,7 +53,7 @@ var accountCompanyProducerExtensionUploadCmd = &cobra.Command{ logging.FromContext(cmd.Context()).Debugf("Found extension with ID: %d", ext.Id) - binaries, err := p.GetExtensionBinaries(cmd.Context(), ext.Id) + binaries, err := p.GetExtensionBinaries(cmd.Context(), ext.Producer.Id, ext.Id) if err != nil { logging.FromContext(cmd.Context()).Debugf("Failed to get extension binaries for extension ID %d: %v", ext.Id, err) return err @@ -110,7 +110,7 @@ var accountCompanyProducerExtensionUploadCmd = &cobra.Command{ }, } - foundBinary, err = p.CreateExtensionBinary(cmd.Context(), ext.Id, create) + foundBinary, err = p.CreateExtensionBinary(cmd.Context(), ext.Producer.Id, ext.Id, create) if err != nil { logging.FromContext(cmd.Context()).Debugf("Failed to create extension binary: %v", err) return fmt.Errorf("create extension binary: %w", err) @@ -134,7 +134,7 @@ var accountCompanyProducerExtensionUploadCmd = &cobra.Command{ logging.FromContext(cmd.Context()).Debugf("Updating extension binary info for extension ID %d, binary ID %d", ext.Id, foundBinary.Id) - err = p.UpdateExtensionBinaryInfo(cmd.Context(), ext.Id, update) + err = p.UpdateExtensionBinaryInfo(cmd.Context(), ext.Producer.Id, ext.Id, update) if err != nil { logging.FromContext(cmd.Context()).Debugf("Failed to update extension binary info: %v", err) return err @@ -143,7 +143,7 @@ var accountCompanyProducerExtensionUploadCmd = &cobra.Command{ logging.FromContext(cmd.Context()).Infof("Updated changelog. Uploading now the zip to remote") logging.FromContext(cmd.Context()).Debugf("Uploading zip file from path: %s", path) - err = p.UpdateExtensionBinaryFile(cmd.Context(), ext.Id, foundBinary.Id, path) + err = p.UpdateExtensionBinaryFile(cmd.Context(), ext.Producer.Id, ext.Id, foundBinary.Id, path) if err != nil { logging.FromContext(cmd.Context()).Debugf("UpdateExtensionBinaryFile returned error: %v", err) if strings.Contains(err.Error(), "BinariesException-40") { diff --git a/cmd/account/account_producer_info.go b/cmd/account/account_producer_info.go deleted file mode 100644 index e5a7b2d5..00000000 --- a/cmd/account/account_producer_info.go +++ /dev/null @@ -1,36 +0,0 @@ -package account - -import ( - "fmt" - - "github.com/spf13/cobra" - - "github.com/shopware/shopware-cli/logging" -) - -var accountProducerInfoCmd = &cobra.Command{ - Use: "info", - Short: "List information about your producer account", - Long: ``, - RunE: func(cmd *cobra.Command, _ []string) error { - p, err := services.AccountClient.Producer(cmd.Context()) - if err != nil { - return fmt.Errorf("cannot get producer endpoint: %w", err) - } - - profile, err := p.Profile(cmd.Context()) - if err != nil { - return fmt.Errorf("cannot get producer profile: %w", err) - } - - logging.FromContext(cmd.Context()).Infof("Name: %s", profile.Name) - logging.FromContext(cmd.Context()).Infof("Prefix: %s", profile.Prefix) - logging.FromContext(cmd.Context()).Infof("Website: %s", profile.Website) - - return nil - }, -} - -func init() { - accountCompanyProducerCmd.AddCommand(accountProducerInfoCmd) -} diff --git a/cmd/root.go b/cmd/root.go index 01989969..d5e066a0 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -67,7 +67,7 @@ func init() { AccountClient: nil, }, nil } - client, err := accountApi.NewApi(rootCmd.Context(), conf) + client, err := accountApi.NewApi(rootCmd.Context(), nil) if err != nil { return nil, err } diff --git a/go.mod b/go.mod index 7e437ab9..4b644c55 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,6 @@ require ( github.com/DATA-DOG/go-sqlmock v1.5.2 github.com/NYTimes/gziphandler v1.1.1 github.com/bep/godartsass/v2 v2.5.0 - github.com/caarlos0/env/v9 v9.0.0 github.com/cespare/xxhash/v2 v2.3.0 github.com/charmbracelet/huh v0.8.0 github.com/charmbracelet/huh/spinner v0.0.0-20250826160502-fa7f8a27cd5c diff --git a/go.sum b/go.sum index 59f006a1..d11c5d9f 100644 --- a/go.sum +++ b/go.sum @@ -46,8 +46,6 @@ github.com/bep/godartsass/v2 v2.5.0 h1:tKRvwVdyjCIr48qgtLa4gHEdtRkPF8H1OeEhJAEv7 github.com/bep/godartsass/v2 v2.5.0/go.mod h1:rjsi1YSXAl/UbsGL85RLDEjRKdIKUlMQHr6ChUNYOFU= github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= -github.com/caarlos0/env/v9 v9.0.0 h1:SI6JNsOA+y5gj9njpgybykATIylrRMklbs5ch6wO6pc= -github.com/caarlos0/env/v9 v9.0.0/go.mod h1:ye5mlCVMYh6tZ+vCgrs/B95sj88cg5Tlnc0XIzgZ020= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= diff --git a/internal/account-api/client.go b/internal/account-api/client.go index df66c816..493b46b1 100644 --- a/internal/account-api/client.go +++ b/internal/account-api/client.go @@ -10,6 +10,8 @@ import ( "path/filepath" "time" + "golang.org/x/oauth2" + "github.com/shopware/shopware-cli/logging" ) @@ -20,9 +22,7 @@ func SetUserAgent(userAgent string) { } type Client struct { - Token token `json:"token"` - ActiveMembership Membership `json:"active_membership"` - Memberships []Membership `json:"memberships"` + Token *oauth2.Token `json:"token"` } func (c *Client) NewAuthenticatedRequest(ctx context.Context, method, path string, body io.Reader) (*http.Request, error) { @@ -34,7 +34,7 @@ func (c *Client) NewAuthenticatedRequest(ctx context.Context, method, path strin r.Header.Set("content-type", "application/json") r.Header.Set("accept", "application/json") - r.Header.Set("x-shopware-token", c.Token.Token) + c.Token.SetAuthHeader(r) r.Header.Set("user-agent", httpUserAgent) return r, nil @@ -64,38 +64,15 @@ func (*Client) doRequest(request *http.Request) ([]byte, error) { return data, nil } -func (c *Client) GetActiveCompanyID() int { - return c.Token.UserID -} - -func (c *Client) GetUserID() int { - return c.Token.UserAccountID -} - -func (c *Client) GetActiveMembership() Membership { - return c.ActiveMembership -} - -func (c *Client) GetMemberships() []Membership { - return c.Memberships -} - func (c *Client) isTokenValid() bool { - loc, err := time.LoadLocation(c.Token.Expire.Timezone) - if err != nil { - return false - } - - expire, err := time.ParseInLocation("2006-01-02 15:04:05.000000", c.Token.Expire.Date, loc) - if err != nil { + if c.Token == nil { return false } - // When it will be expire in the next minute. Respond with false - return expire.UTC().Sub(time.Now().UTC()).Seconds() > 60 + return time.Until(c.Token.Expiry) > 60 } -const CacheFileName = "shopware-api-client-token.json" +const CacheFileName = "shopware-api-oauth2-token.json" func getApiTokenCacheFilePath() (string, error) { cacheDir, err := os.UserCacheDir() @@ -129,7 +106,6 @@ func createApiFromTokenCache(ctx context.Context) (*Client, error) { } logging.FromContext(ctx).Debugf("Using token cache from %s", tokenFilePath) - logging.FromContext(ctx).Debugf("Impersonating currently as %s (%d)", client.ActiveMembership.Company.Name, client.ActiveMembership.Company.Id) if !client.isTokenValid() { return nil, fmt.Errorf("token is expired") diff --git a/internal/account-api/login.go b/internal/account-api/login.go index 0e6a0db2..20b2047d 100644 --- a/internal/account-api/login.go +++ b/internal/account-api/login.go @@ -1,91 +1,34 @@ package account_api import ( - "bytes" "context" - "encoding/json" "fmt" - "io" - "net/http" + + "golang.org/x/oauth2" "github.com/shopware/shopware-cli/logging" ) -const ApiUrl = "https://api.shopware.com" - -type AccountConfig interface { - GetAccountEmail() string - GetAccountPassword() string -} - -func NewApi(ctx context.Context, config AccountConfig) (*Client, error) { +func NewApi(ctx context.Context, token *oauth2.Token) (*Client, error) { errorFormat := "login: %v" - request := LoginRequest{ - Email: config.GetAccountEmail(), - Password: config.GetAccountPassword(), - } - client, err := createApiFromTokenCache(ctx) - - if err == nil { - return client, nil - } - - s, err := json.Marshal(request) - if err != nil { - return nil, fmt.Errorf(errorFormat, err) - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, ApiUrl+"/accesstokens", bytes.NewBuffer(s)) - if err != nil { - return nil, fmt.Errorf("create access token request: %w", err) - } - - req.Header.Set("Content-Type", "application/json") - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, fmt.Errorf(errorFormat, err) - } - - defer func() { - if err := resp.Body.Close(); err != nil { - logging.FromContext(ctx).Errorf("Cannot close response body: %v", err) - } - }() - - data, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf(errorFormat, err) - } - - if resp.StatusCode != 200 { - logging.FromContext(ctx).Debugf("Login failed with response: %s", string(data)) - return nil, fmt.Errorf("login failed. Check your credentials") - } + client, _ := createApiFromTokenCache(ctx) - var token token - if err := json.Unmarshal(data, &token); err != nil { - return nil, fmt.Errorf(errorFormat, err) + if client == nil { + client = &Client{} } - memberships, err := fetchMemberships(ctx, token) - if err != nil { - return nil, err + if token != nil { + client.Token = token } - var activeMemberShip Membership - - for _, membership := range memberships { - if membership.Company.Id == token.UserID { - activeMemberShip = membership + if !client.isTokenValid() { + newToken, err := InteractiveLogin(ctx) + if err != nil { + return nil, fmt.Errorf(errorFormat, err) } - } - client = &Client{ - Token: token, - Memberships: memberships, - ActiveMembership: activeMemberShip, + client.Token = newToken } if err := saveApiTokenToTokenCache(client); err != nil { @@ -94,163 +37,3 @@ func NewApi(ctx context.Context, config AccountConfig) (*Client, error) { return client, nil } - -func fetchMemberships(ctx context.Context, token token) ([]Membership, error) { - r, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/account/%d/memberships", ApiUrl, token.UserAccountID), http.NoBody) - r.Header.Set("x-shopware-token", token.Token) - - if err != nil { - return nil, err - } - - resp, err := http.DefaultClient.Do(r) - if err != nil { - return nil, err - } - - defer func() { - if err := resp.Body.Close(); err != nil { - logging.FromContext(ctx).Errorf("Cannot close response body: %v", err) - } - }() - - data, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("fetchMemberships: %v", err) - } - - if resp.StatusCode != 200 { - return nil, fmt.Errorf(string(data)+" but got status code %d", resp.StatusCode) - } - - var companies []Membership - if err := json.Unmarshal(data, &companies); err != nil { - return nil, fmt.Errorf("fetchMemberships: %v", err) - } - - return companies, nil -} - -type token struct { - Token string `json:"token"` - Expire tokenExpire `json:"expire"` - UserAccountID int `json:"userAccountId"` - UserID int `json:"userId"` - LegacyLogin bool `json:"legacyLogin"` -} - -type tokenExpire struct { - Date string `json:"date"` - TimezoneType int `json:"timezone_type"` - Timezone string `json:"timezone"` -} - -type LoginRequest struct { - Email string `json:"shopwareId"` - Password string `json:"password"` -} - -func (l LoginRequest) GetAccountEmail() string { - return l.Email -} - -func (l LoginRequest) GetAccountPassword() string { - return l.Password -} - -type Membership struct { - Id int `json:"id"` - CreationDate string `json:"creationDate"` - Active bool `json:"active"` - Member struct { - Id int `json:"id"` - Email string `json:"email"` - AvatarUrl interface{} `json:"avatarUrl"` - PersonalData struct { - Id int `json:"id"` - Salutation struct { - Id int `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - } `json:"salutation"` - FirstName string `json:"firstName"` - LastName string `json:"lastName"` - Locale struct { - Id int `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - } `json:"locale"` - } `json:"personalData"` - } `json:"member"` - Company struct { - Id int `json:"id"` - Name string `json:"name"` - CustomerNumber string `json:"customerNumber"` - } `json:"company"` - Roles []struct { - Id int `json:"id"` - Name string `json:"name"` - CreationDate string `json:"creationDate"` - Company interface{} `json:"company"` - Permissions []struct { - Id int `json:"id"` - Context string `json:"context"` - Name string `json:"name"` - } `json:"permissions"` - } `json:"roles"` -} - -func (m Membership) GetRoles() []string { - roles := make([]string, 0) - - for _, role := range m.Roles { - roles = append(roles, role.Name) - } - - return roles -} - -type changeMembershipRequest struct { - SelectedMembership struct { - Id int `json:"id"` - } `json:"membership"` -} - -func (c *Client) ChangeActiveMembership(ctx context.Context, selected Membership) error { - s, err := json.Marshal(changeMembershipRequest{SelectedMembership: struct { - Id int `json:"id"` - }(struct{ Id int }{Id: selected.Id})}) - if err != nil { - return fmt.Errorf("ChangeActiveMembership: %v", err) - } - - r, err := c.NewAuthenticatedRequest(ctx, "POST", fmt.Sprintf("%s/account/%d/memberships/change", ApiUrl, c.GetUserID()), bytes.NewBuffer(s)) - if err != nil { - return err - } - - resp, err := http.DefaultClient.Do(r) - if err != nil { - return err - } - - defer func() { - if err := resp.Body.Close(); err != nil { - logging.FromContext(ctx).Errorf("ChangeActiveMembership: %v", err) - } - }() - _, _ = io.Copy(io.Discard, resp.Body) - - if resp.StatusCode == 200 { - c.ActiveMembership = selected - c.Token.UserID = selected.Company.Id - - if err := saveApiTokenToTokenCache(c); err != nil { - return err - } - - return nil - } - - return fmt.Errorf("could not change active membership due http error %d", resp.StatusCode) -} diff --git a/internal/account-api/oauth2.go b/internal/account-api/oauth2.go new file mode 100644 index 00000000..dbecba51 --- /dev/null +++ b/internal/account-api/oauth2.go @@ -0,0 +1,106 @@ +package account_api + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "net" + "net/http" + "strings" + + "golang.org/x/oauth2" + + "github.com/shopware/shopware-cli/logging" +) + +func InteractiveLogin(ctx context.Context) (*oauth2.Token, error) { + client := &oauth2.Config{ + ClientID: OIDCClientID, + Endpoint: oauth2.Endpoint{ + AuthURL: fmt.Sprintf("%s/oauth2/auth", OIDCEndpoint), + TokenURL: fmt.Sprintf("%s/oauth2/token", OIDCEndpoint), + AuthStyle: oauth2.AuthStyleInParams, + }, + } + + var ( + state = generateRandomState() + pkceVerifier = oauth2.GenerateVerifier() + serverErr = make(chan error) + serverToken = make(chan *oauth2.Token) + ) + + l, err := net.Listen("tcp", "localhost:61472") + if err != nil { + return nil, fmt.Errorf("failed to allocate port for OAuth2 callback handler, try again later: %w", err) + } + + client.RedirectURL = strings.ReplaceAll(fmt.Sprintf("http://%s/callback", l.Addr().String()), "127.0.0.1", "localhost") + + srv := http.Server{ + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer close(serverErr) + defer close(serverToken) + + ctx := r.Context() + if err := r.ParseForm(); err != nil { + serverErr <- fmt.Errorf("failed to parse form: %w", err) + return + } + if s := r.Form.Get("state"); s != state { + serverErr <- fmt.Errorf("state mismatch: expected %q, got %q", state, s) + return + } + if r.Form.Has("error") { + e, d := r.Form.Get("error"), r.Form.Get("error_description") + serverErr <- fmt.Errorf("upstream error: %s: %s", e, d) + return + } + code := r.Form.Get("code") + if code == "" { + serverErr <- fmt.Errorf("missing code") + return + } + t, err := client.Exchange( + ctx, + code, + oauth2.VerifierOption(pkceVerifier), + ) + if err != nil { + serverErr <- fmt.Errorf("failed OAuth2 token exchange: %w", err) + return + } + serverToken <- t + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(`

Login successful

You can close this window now.

`)) + }), + } + go func() { _ = srv.Serve(l) }() + defer srv.Close() + + u := client.AuthCodeURL(state, + oauth2.S256ChallengeOption(pkceVerifier), + oauth2.SetAuthURLParam("scope", OIDCScopes), + oauth2.SetAuthURLParam("response_type", "code"), + ) + + logging.FromContext(ctx).Infof("Please open the following URL in your browser: %s", u) + + select { + case err := <-serverErr: + return nil, fmt.Errorf("failed to handle OAuth2 callback: %w", err) + case t := <-serverToken: + return t, nil + } +} + +func generateRandomState() string { + b := make([]byte, 16) + _, err := rand.Read(b) + if err != nil { + panic(err) + } + return hex.EncodeToString(b) +} diff --git a/internal/account-api/oidc.go b/internal/account-api/oidc.go new file mode 100644 index 00000000..ffade484 --- /dev/null +++ b/internal/account-api/oidc.go @@ -0,0 +1,9 @@ +package account_api + +const ( + OIDCEndpoint = "https://auth-api.shopware.in" + OIDCClientID = "def413d7-4c4e-439f-8b51-74c352436b2f" + OIDCScopes = "openid offline_access email profile extension_management_read_write" + + ApiUrl = "https://next-api.shopware.com" +) diff --git a/internal/account-api/producer.go b/internal/account-api/producer.go index b59164a6..c48230c1 100644 --- a/internal/account-api/producer.go +++ b/internal/account-api/producer.go @@ -13,16 +13,12 @@ import ( ) type ProducerEndpoint struct { - c *Client - producerId int -} - -func (e ProducerEndpoint) GetId() int { - return e.producerId + c *Client + producerIds []int } func (c *Client) Producer(ctx context.Context) (*ProducerEndpoint, error) { - r, err := c.NewAuthenticatedRequest(ctx, "GET", fmt.Sprintf("%s/companies/%d/allocations", ApiUrl, c.GetActiveCompanyID()), nil) + r, err := c.NewAuthenticatedRequest(ctx, "GET", fmt.Sprintf("%s/integrations/shopwarecli/producers", ApiUrl), nil) if err != nil { return nil, err } @@ -32,48 +28,21 @@ func (c *Client) Producer(ctx context.Context) (*ProducerEndpoint, error) { return nil, err } - var allocation companyAllocation - if err := json.Unmarshal(body, &allocation); err != nil { + var producers []Producer + if err := json.Unmarshal(body, &producers); err != nil { return nil, fmt.Errorf("producer.profile: %v", err) } - if !allocation.IsProducer { - return nil, fmt.Errorf("this company is not unlocked as producer") + if len(producers) == 0 { + return nil, fmt.Errorf("producer.profile: no producer found for current user") } - return &ProducerEndpoint{producerId: allocation.ProducerID, c: c}, nil -} - -type companyAllocation struct { - HasShops bool `json:"hasShops"` - HasCommercialShop bool `json:"hasCommercialShop"` - IsEducationMember bool `json:"isEducationMember"` - IsPartner bool `json:"isPartner"` - IsProducer bool `json:"isProducer"` - ProducerID int `json:"producerId"` -} - -func (e ProducerEndpoint) Profile(ctx context.Context) (*Producer, error) { - r, err := e.c.NewAuthenticatedRequest(ctx, "GET", fmt.Sprintf("%s/producers?companyId=%d", ApiUrl, e.c.GetActiveCompanyID()), nil) - if err != nil { - return nil, err + var producerIds []int + for _, p := range producers { + producerIds = append(producerIds, p.Id) } - body, err := e.c.doRequest(r) - if err != nil { - return nil, err - } - - var producers []Producer - if err := json.Unmarshal(body, &producers); err != nil { - return nil, fmt.Errorf("my_profile: %v", err) - } - - for _, profile := range producers { - return &profile, nil - } - - return nil, fmt.Errorf("cannot find a profile") + return &ProducerEndpoint{producerIds: producerIds, c: c}, nil } type Producer struct { @@ -124,9 +93,23 @@ type ListExtensionCriteria struct { } func (e ProducerEndpoint) Extensions(ctx context.Context, criteria *ListExtensionCriteria) ([]Extension, error) { + var allExtensions []Extension + + for _, producerId := range e.producerIds { + extensions, err := e.singleExtensionsByProducer(ctx, criteria, producerId) + if err != nil { + return nil, err + } + allExtensions = append(allExtensions, extensions...) + } + + return allExtensions, nil +} + +func (e ProducerEndpoint) singleExtensionsByProducer(ctx context.Context, criteria *ListExtensionCriteria, producerId int) ([]Extension, error) { encoder := schema.NewEncoder() form := url.Values{} - form.Set("producerId", strconv.FormatInt(int64(e.GetId()), 10)) + form.Set("producerId", strconv.FormatInt(int64(producerId), 10)) err := encoder.Encode(criteria, form) if err != nil { return nil, fmt.Errorf("list_extensions: %v", err) diff --git a/internal/account-api/producer_extension.go b/internal/account-api/producer_extension.go index ae3bf16e..2366bea8 100644 --- a/internal/account-api/producer_extension.go +++ b/internal/account-api/producer_extension.go @@ -67,10 +67,10 @@ type ExtensionCreate struct { Version string `json:"version"` } -func (e ProducerEndpoint) GetExtensionBinaries(ctx context.Context, extensionId int) ([]*ExtensionBinary, error) { +func (e ProducerEndpoint) GetExtensionBinaries(ctx context.Context, producerId int, extensionId int) ([]*ExtensionBinary, error) { errorFormat := "GetExtensionBinaries: %v" - r, err := e.c.NewAuthenticatedRequest(ctx, "GET", fmt.Sprintf("%s/producers/%d/plugins/%d/binaries", ApiUrl, e.producerId, extensionId), nil) + r, err := e.c.NewAuthenticatedRequest(ctx, "GET", fmt.Sprintf("%s/producers/%d/plugins/%d/binaries", ApiUrl, producerId, extensionId), nil) if err != nil { return nil, fmt.Errorf(errorFormat, err) } @@ -88,7 +88,7 @@ func (e ProducerEndpoint) GetExtensionBinaries(ctx context.Context, extensionId return binaries, nil } -func (e ProducerEndpoint) UpdateExtensionBinaryInfo(ctx context.Context, extensionId int, update ExtensionUpdate) error { +func (e ProducerEndpoint) UpdateExtensionBinaryInfo(ctx context.Context, producerId, extensionId int, update ExtensionUpdate) error { errorFormat := "UpdateExtensionBinaryInfo: %v" content, err := json.Marshal(update) @@ -96,7 +96,7 @@ func (e ProducerEndpoint) UpdateExtensionBinaryInfo(ctx context.Context, extensi return fmt.Errorf(errorFormat, err) } - r, err := e.c.NewAuthenticatedRequest(ctx, "PUT", fmt.Sprintf("%s/producers/%d/plugins/%d/binaries/%d", ApiUrl, e.producerId, extensionId, update.Id), bytes.NewReader(content)) + r, err := e.c.NewAuthenticatedRequest(ctx, "PUT", fmt.Sprintf("%s/producers/%d/plugins/%d/binaries/%d", ApiUrl, producerId, extensionId, update.Id), bytes.NewReader(content)) if err != nil { return fmt.Errorf(errorFormat, err) } @@ -106,7 +106,7 @@ func (e ProducerEndpoint) UpdateExtensionBinaryInfo(ctx context.Context, extensi return err } -func (e ProducerEndpoint) CreateExtensionBinary(ctx context.Context, extensionId int, create ExtensionCreate) (*ExtensionBinary, error) { +func (e ProducerEndpoint) CreateExtensionBinary(ctx context.Context, producerId, extensionId int, create ExtensionCreate) (*ExtensionBinary, error) { errorFormat := "CreateExtensionBinary: %v" createPayload, err := json.Marshal(create) @@ -114,7 +114,7 @@ func (e ProducerEndpoint) CreateExtensionBinary(ctx context.Context, extensionId return nil, fmt.Errorf(errorFormat, err) } - r, err := e.c.NewAuthenticatedRequest(ctx, "POST", fmt.Sprintf("%s/producers/%d/plugins/%d/binaries", ApiUrl, e.producerId, extensionId), bytes.NewReader(createPayload)) + r, err := e.c.NewAuthenticatedRequest(ctx, "POST", fmt.Sprintf("%s/producers/%d/plugins/%d/binaries", ApiUrl, producerId, extensionId), bytes.NewReader(createPayload)) if err != nil { return nil, fmt.Errorf(errorFormat, err) } @@ -132,7 +132,7 @@ func (e ProducerEndpoint) CreateExtensionBinary(ctx context.Context, extensionId return binary, nil } -func (e ProducerEndpoint) UpdateExtensionBinaryFile(ctx context.Context, extensionId, binaryId int, zipPath string) error { +func (e ProducerEndpoint) UpdateExtensionBinaryFile(ctx context.Context, producerId, extensionId, binaryId int, zipPath string) error { errorFormat := "UpdateExtensionBinaryFile: %v" var b bytes.Buffer @@ -157,7 +157,7 @@ func (e ProducerEndpoint) UpdateExtensionBinaryFile(ctx context.Context, extensi return fmt.Errorf(errorFormat, err) } - r, err := e.c.NewAuthenticatedRequest(ctx, "POST", fmt.Sprintf("%s/producers/%d/plugins/%d/binaries/%d/file", ApiUrl, e.producerId, extensionId, binaryId), &b) + r, err := e.c.NewAuthenticatedRequest(ctx, "POST", fmt.Sprintf("%s/producers/%d/plugins/%d/binaries/%d/file", ApiUrl, producerId, extensionId, binaryId), &b) if err != nil { return fmt.Errorf(errorFormat, err) } diff --git a/internal/account-api/profile.go b/internal/account-api/profile.go deleted file mode 100644 index 2f43290f..00000000 --- a/internal/account-api/profile.go +++ /dev/null @@ -1,112 +0,0 @@ -package account_api - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - - "github.com/shopware/shopware-cli/logging" -) - -func (c *Client) GetMyProfile(ctx context.Context) (*MyProfile, error) { - errorFormat := "GetMyProfile: %v" - - request, err := c.NewAuthenticatedRequest(ctx, "GET", fmt.Sprintf("%s/account/%d", ApiUrl, c.Token.UserAccountID), nil) - if err != nil { - return nil, fmt.Errorf(errorFormat, err) - } - - resp, err := http.DefaultClient.Do(request) - if err != nil { - return nil, fmt.Errorf(errorFormat, err) - } - - defer func() { - if err := resp.Body.Close(); err != nil { - logging.FromContext(ctx).Errorf("GetMyProfile: %v", err) - } - }() - - data, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf(errorFormat, err) - } - - if resp.StatusCode != 200 { - return nil, fmt.Errorf(errorFormat, err) - } - - var profile MyProfile - if err := json.Unmarshal(data, &profile); err != nil { - return nil, fmt.Errorf(errorFormat, err) - } - - return &profile, nil -} - -type MyProfile struct { - Id int `json:"id"` - Email string `json:"email"` - CreationDate string `json:"creationDate"` - Banned bool `json:"banned"` - Verified bool `json:"verified"` - PersonalData struct { - Id int `json:"id"` - Salutation struct { - Id int `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - } `json:"salutation"` - FirstName string `json:"firstName"` - LastName string `json:"lastName"` - Locale struct { - Id int `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - } `json:"locale"` - } `json:"personalData"` - PartnerMarketingOptIn bool `json:"partnerMarketingOptIn"` - SelectedMembership struct { - Id int `json:"id"` - CreationDate string `json:"creationDate"` - Active bool `json:"active"` - Member struct { - Id int `json:"id"` - Email string `json:"email"` - AvatarUrl interface{} `json:"avatarUrl"` - PersonalData struct { - Id int `json:"id"` - Salutation struct { - Id int `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - } `json:"salutation"` - FirstName string `json:"firstName"` - LastName string `json:"lastName"` - Locale struct { - Id int `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - } `json:"locale"` - } `json:"personalData"` - } `json:"member"` - Company struct { - Id int `json:"id"` - Name string `json:"name"` - CustomerNumber string `json:"customerNumber"` - } `json:"company"` - Roles []struct { - Id int `json:"id"` - Name string `json:"name"` - CreationDate string `json:"creationDate"` - Company interface{} `json:"company"` - Permissions []struct { - Id int `json:"id"` - Context string `json:"context"` - Name string `json:"name"` - } `json:"permissions"` - } `json:"roles"` - } `json:"selectedMembership"` -} diff --git a/internal/account-api/updates.go b/internal/account-api/updates.go index 093e1424..59b7fc12 100644 --- a/internal/account-api/updates.go +++ b/internal/account-api/updates.go @@ -30,7 +30,7 @@ type UpdateCheckExtensionCompatibilityStatus struct { } func GetFutureExtensionUpdates(ctx context.Context, currentVersion string, futureVersion string, extensions []UpdateCheckExtension) ([]UpdateCheckExtensionCompatibility, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.shopware.com/swplatform/autoupdate", nil) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, ApiUrl+"/swplatform/autoupdate", nil) if err != nil { return nil, err } diff --git a/internal/config/config.go b/internal/config/config.go index 95bfc944..511152b3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,7 +6,6 @@ import ( "strconv" "sync" - "github.com/caarlos0/env/v9" "gopkg.in/yaml.v3" ) @@ -26,24 +25,10 @@ type configState struct { type configData struct { Account struct { - Email string `env:"SHOPWARE_CLI_ACCOUNT_EMAIL" yaml:"email"` - Password string `env:"SHOPWARE_CLI_ACCOUNT_PASSWORD" yaml:"password"` - Company int `env:"SHOPWARE_CLI_ACCOUNT_COMPANY" yaml:"company"` + Company int `yaml:"company"` } `yaml:"account"` } -type ExtensionConfig struct { - Name string - Namespace string - ComposerPackage string - ShopwareVersion string - Description string - License string - Label string - ManufacturerLink string - SupportLink string -} - type Config struct{} func init() { @@ -56,8 +41,6 @@ func init() { func defaultConfig() *configData { config := &configData{} - config.Account.Email = "" - config.Account.Password = "" config.Account.Company = 0 return config } @@ -69,6 +52,20 @@ func InitConfig(configPath string) error { return nil } + companyId := os.Getenv("SHOPWARE_CLI_ACCOUNT_COMPANY") + + if len(companyId) > 0 { + state.loadedFromEnv = true + companyIdInt, err := strconv.Atoi(companyId) + if err != nil { + return err + } + state.inner.Account.Company = companyIdInt + state.isReady = true + + return nil + } + if len(configPath) > 0 { state.cfgPath = configPath } else { @@ -80,17 +77,6 @@ func InitConfig(configPath string) error { state.cfgPath = fmt.Sprintf("%s/.shopware-cli.yml", configDir) } - err := env.Parse(state.inner) - if err != nil { - return err - } - if len(state.inner.Account.Email) > 0 { - state.loadedFromEnv = true - - state.isReady = true - - return nil - } if _, err := os.Stat(state.cfgPath); os.IsNotExist(err) { if err := createNewConfig(state.cfgPath); err != nil { return err @@ -103,7 +89,6 @@ func InitConfig(configPath string) error { } err = yaml.Unmarshal(content, &state.inner) - if err != nil { return err } @@ -147,46 +132,12 @@ func createNewConfig(path string) error { return f.Close() } -func (Config) GetAccountEmail() string { - state.mu.RLock() - defer state.mu.RUnlock() - return state.inner.Account.Email -} - -func (Config) GetAccountPassword() string { - state.mu.RLock() - defer state.mu.RUnlock() - return state.inner.Account.Password -} - func (Config) GetAccountCompanyId() int { state.mu.RLock() defer state.mu.RUnlock() return state.inner.Account.Company } -func (Config) SetAccountEmail(email string) error { - state.mu.Lock() - defer state.mu.Unlock() - if state.loadedFromEnv { - return fmt.Errorf(environmentConfigErrorFormat, "account.email", email) - } - state.modified = true - state.inner.Account.Email = email - return nil -} - -func (Config) SetAccountPassword(password string) error { - state.mu.Lock() - defer state.mu.Unlock() - if state.loadedFromEnv { - return fmt.Errorf(environmentConfigErrorFormat, "account.password", "***") - } - state.modified = true - state.inner.Account.Password = password - return nil -} - func (Config) SetAccountCompanyId(id int) error { state.mu.Lock() defer state.mu.Unlock() diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 760b7ff1..5f6602cd 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -18,21 +18,15 @@ func TestParseEnvConfig(t *testing.T) { email, password string companyId int }{ - email: "test@test.com", - password: "test123", companyId: 456, } - t.Setenv("SHOPWARE_CLI_ACCOUNT_EMAIL", testData.email) - t.Setenv("SHOPWARE_CLI_ACCOUNT_PASSWORD", testData.password) t.Setenv("SHOPWARE_CLI_ACCOUNT_COMPANY", strconv.Itoa(testData.companyId)) assert.NoError(t, InitConfig("")) assert.True(t, state.loadedFromEnv) confService := Config{} - assert.Equal(t, testData.email, confService.GetAccountEmail()) - assert.Equal(t, testData.password, confService.GetAccountPassword()) assert.Equal(t, testData.companyId, confService.GetAccountCompanyId()) } @@ -43,8 +37,6 @@ func TestParseFileConfig(t *testing.T) { email, password string companyId int }{ - email: "test@test.com", - password: "test123", companyId: 456, } @@ -56,8 +48,6 @@ func TestParseFileConfig(t *testing.T) { assert.False(t, state.loadedFromEnv) confService := Config{} - assert.Equal(t, testData.email, confService.GetAccountEmail()) - assert.Equal(t, testData.password, confService.GetAccountPassword()) assert.Equal(t, testData.companyId, confService.GetAccountCompanyId()) assert.Equal(t, testConfig, state.cfgPath) } @@ -69,8 +59,6 @@ func TestSaveConfig(t *testing.T) { email, password string companyId int }{ - email: "test@new.com", - password: "test", companyId: 111, } @@ -87,10 +75,6 @@ func TestSaveConfig(t *testing.T) { configService := Config{} - assert.NoError(t, configService.SetAccountEmail(testData.email)) - - assert.NoError(t, configService.SetAccountPassword(testData.password)) - assert.NoError(t, configService.SetAccountCompanyId(testData.companyId)) assert.True(t, state.modified) @@ -105,8 +89,6 @@ func TestSaveConfig(t *testing.T) { var newConf configData assert.NoError(t, yaml.Unmarshal(newConfData, &newConf)) - assert.Equal(t, testData.email, newConf.Account.Email) - assert.Equal(t, testData.password, newConf.Account.Password) assert.Equal(t, testData.companyId, newConf.Account.Company) } @@ -122,16 +104,12 @@ func TestDontWriteEnvConfig(t *testing.T) { companyId: 456, } - t.Setenv("SHOPWARE_CLI_ACCOUNT_EMAIL", testData.email) - t.Setenv("SHOPWARE_CLI_ACCOUNT_PASSWORD", testData.password) t.Setenv("SHOPWARE_CLI_ACCOUNT_COMPANY", strconv.Itoa(testData.companyId)) assert.NoError(t, InitConfig("")) assert.True(t, state.loadedFromEnv) confService := Config{} - assert.Error(t, confService.SetAccountEmail("test@foo.com")) - assert.Error(t, confService.SetAccountPassword("S3CR3TF4RT3St")) assert.Error(t, confService.SetAccountCompanyId(111)) }