a project structure is forming; very messy ATM; added subcommands login and version

This commit is contained in:
Dan Anglin 2024-02-21 10:57:37 +00:00
parent 6c297e5242
commit e354456c0e
Signed by: dananglin
GPG key ID: 0C1D44CFBEE68638
13 changed files with 380 additions and 141 deletions

1
.gitignore vendored
View file

@ -1 +1,2 @@
/environment/ /environment/
/enbas

View file

@ -13,6 +13,14 @@ output:
sort-results: true sort-results: true
linters-settings: linters-settings:
depguard:
rules:
main:
files:
- $all
allow:
- $gostd
- codeflow.dananglin.me.uk/apollo/enbas
lll: lll:
line-length: 140 line-length: 140

View file

@ -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
}

View file

@ -3,15 +3,26 @@ package main
import ( import (
"context" "context"
"errors" "errors"
"flag"
"fmt" "fmt"
"os/exec" "os/exec"
"runtime" "runtime"
"strings" "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" "golang.org/x/oauth2"
) )
type loginCommand struct {
*flag.FlagSet
summary string
instance string
}
var errEmptyAccessToken = errors.New("received an empty access token") var errEmptyAccessToken = errors.New("received an empty access token")
var errInstanceNotSet = errors.New("the instance flag is not set")
var consentMessageFormat = ` 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 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 err error
var instance string if c.instance == "" {
return errInstanceNotSet
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)
} }
instance := c.instance
if !strings.HasPrefix(instance, "https") || !strings.HasPrefix(instance, "http") { if !strings.HasPrefix(instance, "https") || !strings.HasPrefix(instance, "http") {
instance = "https://" + instance instance = "https://" + instance
} }
@ -43,17 +65,17 @@ func loginWithOauth2() error {
instance = instance[:len(instance)-1] instance = instance[:len(instance)-1]
} }
authentication := Authentication{ authentication := config.Authentication{
Instance: instance, 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) return fmt.Errorf("unable to register the application; %w", err)
} }
consentPageURL := authCodeURL(client.authentication) consentPageURL := authCodeURL(gtsClient.Authentication)
openLink(consentPageURL) openLink(consentPageURL)
@ -66,17 +88,17 @@ func loginWithOauth2() error {
return fmt.Errorf("failed to read access code; %w", err) 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 { if err != nil {
return fmt.Errorf("unable to get the access token; %w", err) return fmt.Errorf("unable to get the access token; %w", err)
} }
account, err := client.verifyCredentials() account, err := gtsClient.VerifyCredentials()
if err != nil { if err != nil {
return fmt.Errorf("unable to verify the credentials; %w", err) 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 { if err != nil {
return fmt.Errorf("unable to save the authentication details; %w", err) return fmt.Errorf("unable to save the authentication details; %w", err)
} }
@ -86,42 +108,42 @@ func loginWithOauth2() error {
return nil return nil
} }
func authCodeURL(account Authentication) string { func authCodeURL(account config.Authentication) string {
config := oauth2.Config{ config := oauth2.Config{
ClientID: account.ClientID, ClientID: account.ClientID,
ClientSecret: account.ClientSecret, ClientSecret: account.ClientSecret,
Scopes: []string{"read"}, Scopes: []string{"read"},
RedirectURL: redirectUri, RedirectURL: internal.RedirectUri,
Endpoint: oauth2.Endpoint{ Endpoint: oauth2.Endpoint{
AuthURL: account.Instance + "/oauth/authorize", AuthURL: account.Instance + "/oauth/authorize",
TokenURL: account.Instance + "/oauth/token", 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 return url
} }
func addAccessToken(authentication Authentication, code string) (Authentication, error) { func addAccessToken(authentication config.Authentication, code string) (config.Authentication, error) {
config := oauth2.Config{ ouauth2Conf := oauth2.Config{
ClientID: authentication.ClientID, ClientID: authentication.ClientID,
ClientSecret: authentication.ClientSecret, ClientSecret: authentication.ClientSecret,
Scopes: []string{"read", "write"}, Scopes: []string{"read", "write"},
RedirectURL: redirectUri, RedirectURL: internal.RedirectUri,
Endpoint: oauth2.Endpoint{ Endpoint: oauth2.Endpoint{
AuthURL: authentication.Instance + "/oauth/authorize", AuthURL: authentication.Instance + "/oauth/authorize",
TokenURL: authentication.Instance + "/oauth/token", TokenURL: authentication.Instance + "/oauth/token",
}, },
} }
token, err := config.Exchange(context.Background(), code) token, err := ouauth2Conf.Exchange(context.Background(), code)
if err != nil { 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 == "" { if token == nil || token.AccessToken == "" {
return Authentication{}, errEmptyAccessToken return config.Authentication{}, errEmptyAccessToken
} }
authentication.AccessToken = token.AccessToken authentication.AccessToken = token.AccessToken

118
cmd/enbas/main.go Normal file
View file

@ -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())
}
}

64
cmd/enbas/version.go Normal file
View file

@ -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
}

View file

@ -5,6 +5,8 @@ package main
import ( import (
"fmt" "fmt"
"os" "os"
"runtime"
"time"
"github.com/magefile/mage/sh" "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) return fmt.Errorf("unable to change to the project's root directory; %w", err)
} }
main := "main.go" flags := ldflags()
return sh.Run("go", "build", "-o", binary, main) return sh.Run("go", "build", "-ldflags="+flags, "-a", "-o", binary, "./cmd/enbas")
} }
// Clean clean the workspace. // Clean clean the workspace.
@ -79,3 +81,31 @@ func changeToProjectRoot() error {
return nil 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
}

87
internal/client/client.go Normal file
View file

@ -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
}

View file

@ -1,4 +1,4 @@
package main package client
import ( import (
"bytes" "bytes"
@ -6,6 +6,8 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"codeflow.dananglin.me.uk/apollo/enbas/internal"
) )
type RegisterRequest struct { type RegisterRequest struct {
@ -25,12 +27,12 @@ type RegisterResponse struct {
Website string `json:"website"` Website string `json:"website"`
} }
func (g *gtsClient) register() error { func (g *Client) Register() error {
params := RegisterRequest{ params := RegisterRequest{
ClientName: applicationName, ClientName: internal.ApplicationName,
RedirectUris: redirectUri, RedirectUris: internal.RedirectUri,
Scopes: "read write", Scopes: "read write",
Website: applicationWebsite, Website: internal.ApplicationWebsite,
} }
data, err := json.Marshal(params) data, err := json.Marshal(params)
@ -41,9 +43,9 @@ func (g *gtsClient) register() error {
requestBody := bytes.NewBuffer(data) requestBody := bytes.NewBuffer(data)
path := "/api/v1/apps" 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() defer cancel()
request, err := http.NewRequestWithContext(ctx, http.MethodPost, url, requestBody) 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) return fmt.Errorf("received an error after sending the registration request; %w", err)
} }
g.authentication.ClientID = registerResponse.ClientID g.Authentication.ClientID = registerResponse.ClientID
g.authentication.ClientSecret = registerResponse.ClientSecret g.Authentication.ClientSecret = registerResponse.ClientSecret
return nil return nil
} }

View file

@ -1,4 +1,4 @@
package main package config
import ( import (
"encoding/json" "encoding/json"
@ -7,6 +7,8 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"codeflow.dananglin.me.uk/apollo/enbas/internal"
) )
type AuthenticationConfig struct { type AuthenticationConfig struct {
@ -21,7 +23,7 @@ type Authentication struct {
AccessToken string `json:"accessToken"` 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 { if err := ensureConfigDir(); err != nil {
return "", fmt.Errorf("unable to ensure the configuration directory; %w", err) return "", fmt.Errorf("unable to ensure the configuration directory; %w", err)
} }
@ -97,7 +99,7 @@ func configDir() string {
rootDir = "." rootDir = "."
} }
return filepath.Join(rootDir, applicationName) return filepath.Join(rootDir, internal.ApplicationName)
} }
func ensureConfigDir() error { func ensureConfigDir() error {

8
internal/internal.go Normal file
View file

@ -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"
)

View file

@ -1,4 +1,4 @@
package main package model
type Account struct { type Account struct {
Acct string `json:"acct"` Acct string `json:"acct"`

20
main.go
View file

@ -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)
}
}