From e354456c0e04662b94a965604b8c10d83811c56f Mon Sep 17 00:00:00 2001 From: Dan Anglin Date: Wed, 21 Feb 2024 10:57:37 +0000 Subject: [PATCH] a project structure is forming; very messy ATM; added subcommands login and version --- .gitignore | 1 + .golangci.yaml | 8 ++ client.go | 83 --------------- login.go => cmd/enbas/login.go | 68 ++++++++---- cmd/enbas/main.go | 118 +++++++++++++++++++++ cmd/enbas/version.go | 64 +++++++++++ internal/build/magefiles/mage.go | 34 +++++- internal/client/client.go | 87 +++++++++++++++ register.go => internal/client/register.go | 20 ++-- config.go => internal/config/config.go | 8 +- internal/internal.go | 8 ++ account.go => internal/model/account.go | 2 +- main.go | 20 ---- 13 files changed, 380 insertions(+), 141 deletions(-) delete mode 100644 client.go rename login.go => cmd/enbas/login.go (57%) create mode 100644 cmd/enbas/main.go create mode 100644 cmd/enbas/version.go create mode 100644 internal/client/client.go rename register.go => internal/client/register.go (73%) rename config.go => internal/config/config.go (93%) create mode 100644 internal/internal.go rename account.go => internal/model/account.go (99%) delete mode 100644 main.go diff --git a/.gitignore b/.gitignore index c493042..b53949b 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /environment/ +/enbas diff --git a/.golangci.yaml b/.golangci.yaml index 5998567..56676db 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -13,6 +13,14 @@ output: sort-results: true linters-settings: + depguard: + rules: + main: + files: + - $all + allow: + - $gostd + - codeflow.dananglin.me.uk/apollo/enbas lll: line-length: 140 diff --git a/client.go b/client.go deleted file mode 100644 index 8f23b88..0000000 --- a/client.go +++ /dev/null @@ -1,83 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "time" -) - -type gtsClient struct { - authentication Authentication - httpClient http.Client - userAgent string - timeout time.Duration -} - -func newGtsClient(authentication Authentication) *gtsClient { - httpClient := http.Client{} - - client := gtsClient{ - authentication: authentication, - httpClient: httpClient, - userAgent: userAgent, - timeout: 5 * time.Second, - } - - return &client -} - -func (g *gtsClient) verifyCredentials() (Account, error) { - path := "/api/v1/accounts/verify_credentials" - url := g.authentication.Instance + path - - ctx, cancel := context.WithTimeout(context.Background(), g.timeout) - defer cancel() - - request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return Account{}, fmt.Errorf("unable to create the HTTP request; %w", err) - } - - var account Account - - if err := g.sendRequest(request, &account); err != nil { - return Account{}, fmt.Errorf("received an error after sending the request to verify the credentials; %w", err) - } - - return account, nil -} - -func (g *gtsClient) sendRequest(request *http.Request, object any) error { - request.Header.Set("Content-Type", "application/json; charset=utf-8") - request.Header.Set("Accept", "application/json; charset=utf-8") - request.Header.Set("User-Agent", g.userAgent) - - if len(g.authentication.AccessToken) > 0 { - request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", g.authentication.AccessToken)) - } - - response, err := g.httpClient.Do(request) - if err != nil { - return fmt.Errorf("received an error after sending the request; %w", err) - } - - defer response.Body.Close() - - if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusBadRequest { - return fmt.Errorf( - "did not receive an OK response from the GoToSocial server; got %d", - response.StatusCode, - ) - } - - if err := json.NewDecoder(response.Body).Decode(object); err != nil { - return fmt.Errorf( - "unable to decode the response from the GoToSocial server; %w", - err, - ) - } - - return nil -} diff --git a/login.go b/cmd/enbas/login.go similarity index 57% rename from login.go rename to cmd/enbas/login.go index c7c6a65..c91cf20 100644 --- a/login.go +++ b/cmd/enbas/login.go @@ -3,15 +3,26 @@ package main import ( "context" "errors" + "flag" "fmt" "os/exec" "runtime" "strings" + "codeflow.dananglin.me.uk/apollo/enbas/internal" + "codeflow.dananglin.me.uk/apollo/enbas/internal/client" + "codeflow.dananglin.me.uk/apollo/enbas/internal/config" "golang.org/x/oauth2" ) +type loginCommand struct { + *flag.FlagSet + summary string + instance string +} + var errEmptyAccessToken = errors.New("received an empty access token") +var errInstanceNotSet = errors.New("the instance flag is not set") var consentMessageFormat = ` You'll need to sign into your GoToSocial's consent page in order to generate the out-of-band token to continue with @@ -24,17 +35,28 @@ Once you have the code please copy and paste it below. ` -func loginWithOauth2() error { +func newLoginCommand(name, summary string) *loginCommand { + command := loginCommand{ + FlagSet: flag.NewFlagSet(name, flag.ExitOnError), + summary: summary, + } + + command.StringVar(&command.instance, "instance", "", "specify the instance that you want to login to.") + + command.Usage = commandUsageFunc(command.Name(), command.summary, command.FlagSet) + + return &command +} + +func (c *loginCommand) Execute() error { var err error - var instance string - - fmt.Print("Please enter the instance URL: ") - - if _, err := fmt.Scanln(&instance); err != nil { - return fmt.Errorf("unable to read user input for the instance value; %w", err) + if c.instance == "" { + return errInstanceNotSet } + instance := c.instance + if !strings.HasPrefix(instance, "https") || !strings.HasPrefix(instance, "http") { instance = "https://" + instance } @@ -43,17 +65,17 @@ func loginWithOauth2() error { instance = instance[:len(instance)-1] } - authentication := Authentication{ + authentication := config.Authentication{ Instance: instance, } - client := newGtsClient(authentication) + gtsClient := client.NewClient(authentication) - if err := client.register(); err != nil { + if err := gtsClient.Register(); err != nil { return fmt.Errorf("unable to register the application; %w", err) } - consentPageURL := authCodeURL(client.authentication) + consentPageURL := authCodeURL(gtsClient.Authentication) openLink(consentPageURL) @@ -66,17 +88,17 @@ func loginWithOauth2() error { return fmt.Errorf("failed to read access code; %w", err) } - client.authentication, err = addAccessToken(client.authentication, code) + gtsClient.Authentication, err = addAccessToken(gtsClient.Authentication, code) if err != nil { return fmt.Errorf("unable to get the access token; %w", err) } - account, err := client.verifyCredentials() + account, err := gtsClient.VerifyCredentials() if err != nil { return fmt.Errorf("unable to verify the credentials; %w", err) } - loginName, err := saveAuthenticationConfig(account.Username, client.authentication) + loginName, err := config.SaveAuthentication(account.Username, gtsClient.Authentication) if err != nil { return fmt.Errorf("unable to save the authentication details; %w", err) } @@ -86,42 +108,42 @@ func loginWithOauth2() error { return nil } -func authCodeURL(account Authentication) string { +func authCodeURL(account config.Authentication) string { config := oauth2.Config{ ClientID: account.ClientID, ClientSecret: account.ClientSecret, Scopes: []string{"read"}, - RedirectURL: redirectUri, + RedirectURL: internal.RedirectUri, Endpoint: oauth2.Endpoint{ AuthURL: account.Instance + "/oauth/authorize", TokenURL: account.Instance + "/oauth/token", }, } - url := config.AuthCodeURL("state", oauth2.AccessTypeOffline) + fmt.Sprintf("&client_name=%s", applicationName) + url := config.AuthCodeURL("state", oauth2.AccessTypeOffline) + fmt.Sprintf("&client_name=%s", internal.ApplicationName) return url } -func addAccessToken(authentication Authentication, code string) (Authentication, error) { - config := oauth2.Config{ +func addAccessToken(authentication config.Authentication, code string) (config.Authentication, error) { + ouauth2Conf := oauth2.Config{ ClientID: authentication.ClientID, ClientSecret: authentication.ClientSecret, Scopes: []string{"read", "write"}, - RedirectURL: redirectUri, + RedirectURL: internal.RedirectUri, Endpoint: oauth2.Endpoint{ AuthURL: authentication.Instance + "/oauth/authorize", TokenURL: authentication.Instance + "/oauth/token", }, } - token, err := config.Exchange(context.Background(), code) + token, err := ouauth2Conf.Exchange(context.Background(), code) if err != nil { - return Authentication{}, fmt.Errorf("unable to exchange the code for an access token; %w", err) + return config.Authentication{}, fmt.Errorf("unable to exchange the code for an access token; %w", err) } if token == nil || token.AccessToken == "" { - return Authentication{}, errEmptyAccessToken + return config.Authentication{}, errEmptyAccessToken } authentication.AccessToken = token.AccessToken diff --git a/cmd/enbas/main.go b/cmd/enbas/main.go new file mode 100644 index 0000000..88fbdf8 --- /dev/null +++ b/cmd/enbas/main.go @@ -0,0 +1,118 @@ +package main + +import ( + "flag" + "fmt" + "os" + "slices" + "strings" +) + +const ( + login string = "login" + version string = "version" +) + +type Executor interface { + Parse([]string) error + Name() string + Execute() error +} + +func main() { + summaries := map[string]string{ + login: "login to an account on GoToSocial", + version: "print the application's version and build information", + } + + flag.Usage = enbasUsageFunc(summaries) + + flag.Parse() + + if flag.NArg() < 1 { + flag.Usage() + os.Exit(0) + } + + subcommand := flag.Arg(0) + args := flag.Args()[1:] + + var executor Executor + + switch subcommand { + case login: + executor = newLoginCommand(login, summaries[login]) + case version: + executor = newVersionCommand(version, summaries[version]) + default: + fmt.Printf("ERROR: Unknown subcommand: %s\n", subcommand) + flag.Usage() + os.Exit(1) + } + + if err := executor.Parse(args); err != nil { + fmt.Printf("ERROR: Unable to parse the command line flags; %v.\n", err) + os.Exit(1) + } + + if err := executor.Execute(); err != nil { + fmt.Printf("ERROR: Unable to run %q; %v.\n", executor.Name(), err) + os.Exit(1) + } +} + +func commandUsageFunc(name, summary string, flagset *flag.FlagSet) func() { + return func() { + var builder strings.Builder + + fmt.Fprintf(&builder, "SUMMARY:\n %s - %s\n\nUSAGE:\n enbas %s [flags]\n\nFLAGS:", name, summary, name) + + flagset.VisitAll(func(f *flag.Flag) { + fmt.Fprintf(&builder, "\n -%s, --%s\n %s", f.Name, f.Name, f.Usage) + }) + + builder.WriteString("\n") + + w := flag.CommandLine.Output() + + fmt.Fprint(w, builder.String()) + } +} + +func enbasUsageFunc(summaries map[string]string) func() { + cmds := make([]string, len(summaries)) + ind := 0 + + for k := range summaries { + cmds[ind] = k + ind++ + } + + slices.Sort(cmds) + + return func() { + var builder strings.Builder + + builder.WriteString("SUMMARY:\n enbas - A GoToSocial client for the terminal.\n\n") + + //if binaryVersion != "" { + // builder.WriteString("VERSION:\n " + binaryVersion + "\n\n") + //} + + builder.WriteString("USAGE:\n enbas [flags]\n enbas [command]\n\nCOMMANDS:") + + for _, cmd := range cmds { + fmt.Fprintf(&builder, "\n %s\t%s", cmd, summaries[cmd]) + } + + builder.WriteString("\n\nFLAGS:\n -help, --help\n print the help message\n") + flag.VisitAll(func(f *flag.Flag) { + fmt.Fprintf(&builder, "\n -%s, --%s\n %s\n", f.Name, f.Name, f.Usage) + }) + + builder.WriteString("\nUse \"enbas [command] --help\" for more information about a command.\n") + + w := flag.CommandLine.Output() + fmt.Fprint(w, builder.String()) + } +} diff --git a/cmd/enbas/version.go b/cmd/enbas/version.go new file mode 100644 index 0000000..61bebe0 --- /dev/null +++ b/cmd/enbas/version.go @@ -0,0 +1,64 @@ +package main + +import ( + "flag" + "fmt" + "os" + "strings" +) + +var ( + binaryVersion string + buildTime string + goVersion string + gitCommit string +) + +type versionCommand struct { + *flag.FlagSet + summary string + showFullVersion bool + binaryVersion string + buildTime string + goVersion string + gitCommit string +} + +func newVersionCommand(name, summary string) *versionCommand { + command := versionCommand{ + FlagSet: flag.NewFlagSet(name, flag.ExitOnError), + binaryVersion: binaryVersion, + buildTime: buildTime, + goVersion: goVersion, + gitCommit: gitCommit, + summary: summary, + showFullVersion: false, + } + + command.BoolVar(&command.showFullVersion, "full", false, "prints the full build information") + + command.Usage = commandUsageFunc(command.Name(), command.summary, command.FlagSet) + + return &command +} + +func (c *versionCommand) Execute() error { + var builder strings.Builder + + if c.showFullVersion { + fmt.Fprintf( + &builder, + "Enbas\n Version: %s\n Git commit: %s\n Go version: %s\n Build date: %s\n", + c.binaryVersion, + c.gitCommit, + c.goVersion, + c.buildTime, + ) + } else { + fmt.Fprintln(&builder, c.binaryVersion) + } + + fmt.Fprint(os.Stdout, builder.String()) + + return nil +} diff --git a/internal/build/magefiles/mage.go b/internal/build/magefiles/mage.go index f0eb0cf..4a9595f 100644 --- a/internal/build/magefiles/mage.go +++ b/internal/build/magefiles/mage.go @@ -5,6 +5,8 @@ package main import ( "fmt" "os" + "runtime" + "time" "github.com/magefile/mage/sh" ) @@ -51,8 +53,8 @@ func Build() error { return fmt.Errorf("unable to change to the project's root directory; %w", err) } - main := "main.go" - return sh.Run("go", "build", "-o", binary, main) + flags := ldflags() + return sh.Run("go", "build", "-ldflags="+flags, "-a", "-o", binary, "./cmd/enbas") } // Clean clean the workspace. @@ -79,3 +81,31 @@ func changeToProjectRoot() error { return nil } + +// ldflags returns the build flags. +func ldflags() string { + ldflagsfmt := "-s -w -X main.binaryVersion=%s -X main.gitCommit=%s -X main.goVersion=%s -X main.buildTime=%s" + buildTime := time.Now().UTC().Format(time.RFC3339) + + return fmt.Sprintf(ldflagsfmt, version(), gitCommit(), runtime.Version(), buildTime) +} + +// version returns the latest git tag using git describe. +func version() string { + version, err := sh.Output("git", "describe", "--tags") + if err != nil { + version = "N/A" + } + + return version +} + +// gitCommit returns the current git commit +func gitCommit() string { + commit, err := sh.Output("git", "rev-parse", "--short", "HEAD") + if err != nil { + commit = "N/A" + } + + return commit +} diff --git a/internal/client/client.go b/internal/client/client.go new file mode 100644 index 0000000..8f95490 --- /dev/null +++ b/internal/client/client.go @@ -0,0 +1,87 @@ +package client + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "codeflow.dananglin.me.uk/apollo/enbas/internal" + "codeflow.dananglin.me.uk/apollo/enbas/internal/config" + "codeflow.dananglin.me.uk/apollo/enbas/internal/model" +) + +type Client struct { + Authentication config.Authentication + HTTPClient http.Client + UserAgent string + Timeout time.Duration +} + +func NewClient(authentication config.Authentication) *Client { + httpClient := http.Client{} + + client := Client{ + Authentication: authentication, + HTTPClient: httpClient, + UserAgent: internal.UserAgent, + Timeout: 5 * time.Second, + } + + return &client +} + +func (g *Client) VerifyCredentials() (model.Account, error) { + path := "/api/v1/accounts/verify_credentials" + url := g.Authentication.Instance + path + + ctx, cancel := context.WithTimeout(context.Background(), g.Timeout) + defer cancel() + + request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return model.Account{}, fmt.Errorf("unable to create the HTTP request; %w", err) + } + + var account model.Account + + if err := g.sendRequest(request, &account); err != nil { + return model.Account{}, fmt.Errorf("received an error after sending the request to verify the credentials; %w", err) + } + + return account, nil +} + +func (g *Client) sendRequest(request *http.Request, object any) error { + request.Header.Set("Content-Type", "application/json; charset=utf-8") + request.Header.Set("Accept", "application/json; charset=utf-8") + request.Header.Set("User-Agent", g.UserAgent) + + if len(g.Authentication.AccessToken) > 0 { + request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", g.Authentication.AccessToken)) + } + + response, err := g.HTTPClient.Do(request) + if err != nil { + return fmt.Errorf("received an error after sending the request; %w", err) + } + + defer response.Body.Close() + + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusBadRequest { + return fmt.Errorf( + "did not receive an OK response from the GoToSocial server; got %d", + response.StatusCode, + ) + } + + if err := json.NewDecoder(response.Body).Decode(object); err != nil { + return fmt.Errorf( + "unable to decode the response from the GoToSocial server; %w", + err, + ) + } + + return nil +} diff --git a/register.go b/internal/client/register.go similarity index 73% rename from register.go rename to internal/client/register.go index 72e8101..36d11fd 100644 --- a/register.go +++ b/internal/client/register.go @@ -1,4 +1,4 @@ -package main +package client import ( "bytes" @@ -6,6 +6,8 @@ import ( "encoding/json" "fmt" "net/http" + + "codeflow.dananglin.me.uk/apollo/enbas/internal" ) type RegisterRequest struct { @@ -25,12 +27,12 @@ type RegisterResponse struct { Website string `json:"website"` } -func (g *gtsClient) register() error { +func (g *Client) Register() error { params := RegisterRequest{ - ClientName: applicationName, - RedirectUris: redirectUri, + ClientName: internal.ApplicationName, + RedirectUris: internal.RedirectUri, Scopes: "read write", - Website: applicationWebsite, + Website: internal.ApplicationWebsite, } data, err := json.Marshal(params) @@ -41,9 +43,9 @@ func (g *gtsClient) register() error { requestBody := bytes.NewBuffer(data) path := "/api/v1/apps" - url := g.authentication.Instance + path + url := g.Authentication.Instance + path - ctx, cancel := context.WithTimeout(context.Background(), g.timeout) + ctx, cancel := context.WithTimeout(context.Background(), g.Timeout) defer cancel() request, err := http.NewRequestWithContext(ctx, http.MethodPost, url, requestBody) @@ -57,8 +59,8 @@ func (g *gtsClient) register() error { return fmt.Errorf("received an error after sending the registration request; %w", err) } - g.authentication.ClientID = registerResponse.ClientID - g.authentication.ClientSecret = registerResponse.ClientSecret + g.Authentication.ClientID = registerResponse.ClientID + g.Authentication.ClientSecret = registerResponse.ClientSecret return nil } diff --git a/config.go b/internal/config/config.go similarity index 93% rename from config.go rename to internal/config/config.go index ca454b1..da6ea8f 100644 --- a/config.go +++ b/internal/config/config.go @@ -1,4 +1,4 @@ -package main +package config import ( "encoding/json" @@ -7,6 +7,8 @@ import ( "os" "path/filepath" "strings" + + "codeflow.dananglin.me.uk/apollo/enbas/internal" ) type AuthenticationConfig struct { @@ -21,7 +23,7 @@ type Authentication struct { AccessToken string `json:"accessToken"` } -func saveAuthenticationConfig(username string, authentication Authentication) (string, error) { +func SaveAuthentication(username string, authentication Authentication) (string, error) { if err := ensureConfigDir(); err != nil { return "", fmt.Errorf("unable to ensure the configuration directory; %w", err) } @@ -97,7 +99,7 @@ func configDir() string { rootDir = "." } - return filepath.Join(rootDir, applicationName) + return filepath.Join(rootDir, internal.ApplicationName) } func ensureConfigDir() error { diff --git a/internal/internal.go b/internal/internal.go new file mode 100644 index 0000000..980731f --- /dev/null +++ b/internal/internal.go @@ -0,0 +1,8 @@ +package internal + +const ( + ApplicationName = "enbas" + ApplicationWebsite = "https://codeflow.dananglin.me.uk/apollo/enbas" + RedirectUri = "urn:ietf:wg:oauth:2.0:oob" + UserAgent = "Enbas/0.0.0" +) diff --git a/account.go b/internal/model/account.go similarity index 99% rename from account.go rename to internal/model/account.go index 5e21c31..82af04c 100644 --- a/account.go +++ b/internal/model/account.go @@ -1,4 +1,4 @@ -package main +package model type Account struct { Acct string `json:"acct"` diff --git a/main.go b/main.go deleted file mode 100644 index fa3e2bb..0000000 --- a/main.go +++ /dev/null @@ -1,20 +0,0 @@ -package main - -import ( - "fmt" - "os" -) - -const ( - applicationName = "enbas" - applicationWebsite = "https://codeflow.dananglin.me.uk/apollo/enbas" - redirectUri = "urn:ietf:wg:oauth:2.0:oob" - userAgent = "Enbas/0.0.0" -) - -func main() { - if err := loginWithOauth2(); err != nil { - fmt.Printf("ERROR: Unable to register the application; %v\n", err) - os.Exit(1) - } -}