From 2c5123253a180493c4c59c13edbbb464fa63b7ac Mon Sep 17 00:00:00 2001 From: Dan Anglin Date: Fri, 23 Feb 2024 09:44:57 +0000 Subject: [PATCH] feat: add Enbas Add Enbas code. So far Enbas can: - Allow the user to register the application and log into GTS on their behalf. The scope is limited to read for now. - Show instance details. - Show local and remote accounts. --- .gitignore | 2 + .golangci.yaml | 11 +- cmd/enbas/login.go | 159 +++++++++++++++++++++++++ cmd/enbas/main.go | 75 ++++++++++++ cmd/enbas/show.go | 197 +++++++++++++++++++++++++++++++ cmd/enbas/switch.go | 35 ++++++ cmd/enbas/usage.go | 76 ++++++++++++ cmd/enbas/version.go | 62 ++++++++++ go.mod | 13 +- go.sum | 24 ++++ internal/build/magefiles/mage.go | 34 +++++- internal/client/client.go | 125 ++++++++++++++++++++ internal/client/register.go | 48 ++++++++ internal/config/config.go | 151 +++++++++++++++++++++++ internal/internal.go | 8 ++ internal/model/account.go | 52 ++++++++ internal/model/application.go | 11 ++ internal/model/emoji.go | 9 ++ internal/model/instance_v2.go | 106 +++++++++++++++++ internal/model/status.go | 148 +++++++++++++++++++++++ main.go | 7 -- 21 files changed, 1342 insertions(+), 11 deletions(-) create mode 100644 .gitignore create mode 100644 cmd/enbas/login.go create mode 100644 cmd/enbas/main.go create mode 100644 cmd/enbas/show.go create mode 100644 cmd/enbas/switch.go create mode 100644 cmd/enbas/usage.go create mode 100644 cmd/enbas/version.go create mode 100644 go.sum create mode 100644 internal/client/client.go create mode 100644 internal/client/register.go create mode 100644 internal/config/config.go create mode 100644 internal/internal.go create mode 100644 internal/model/account.go create mode 100644 internal/model/application.go create mode 100644 internal/model/emoji.go create mode 100644 internal/model/instance_v2.go create mode 100644 internal/model/status.go delete mode 100644 main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b53949b --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/environment/ +/enbas diff --git a/.golangci.yaml b/.golangci.yaml index 8549273..56676db 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -13,10 +13,19 @@ output: sort-results: true linters-settings: + depguard: + rules: + main: + files: + - $all + allow: + - $gostd + - codeflow.dananglin.me.uk/apollo/enbas lll: line-length: 140 linters: enable-all: true - # disable: + disable: + #- json fast: false diff --git a/cmd/enbas/login.go b/cmd/enbas/login.go new file mode 100644 index 0000000..6559222 --- /dev/null +++ b/cmd/enbas/login.go @@ -0,0 +1,159 @@ +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 + instance string +} + +var ( + errEmptyAccessToken = errors.New("received an empty access token") + 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 +the application's login process. Your browser may have opened the link to the consent page already. If not, please +copy and paste the link below to your browser: + +%s + +Once you have the code please copy and paste it below. + +` + +func newLoginCommand(name, summary string) *loginCommand { + command := loginCommand{ + FlagSet: flag.NewFlagSet(name, flag.ExitOnError), + instance: "", + } + + command.StringVar(&command.instance, "instance", "", "specify the instance that you want to login to.") + + command.Usage = commandUsageFunc(name, summary, command.FlagSet) + + return &command +} + +func (c *loginCommand) Execute() error { + var err error + + if c.instance == "" { + return errInstanceNotSet + } + + instance := c.instance + + if !strings.HasPrefix(instance, "https") || !strings.HasPrefix(instance, "http") { + instance = "https://" + instance + } + + for strings.HasSuffix(instance, "/") { + instance = instance[:len(instance)-1] + } + + authentication := config.Authentication{ + Instance: instance, + } + + gtsClient := client.NewClient(authentication) + + if err := gtsClient.Register(); err != nil { + return fmt.Errorf("unable to register the application; %w", err) + } + + oauth2Conf := oauth2.Config{ + ClientID: gtsClient.Authentication.ClientID, + ClientSecret: gtsClient.Authentication.ClientSecret, + Scopes: []string{"read"}, + RedirectURL: internal.RedirectUri, + Endpoint: oauth2.Endpoint{ + AuthURL: gtsClient.Authentication.Instance + "/oauth/authorize", + TokenURL: gtsClient.Authentication.Instance + "/oauth/token", + }, + } + + consentPageURL := authCodeURL(oauth2Conf) + + openLink(consentPageURL) + + fmt.Printf(consentMessageFormat, consentPageURL) + + var code string + fmt.Print("Out-of-band token: ") + + if _, err := fmt.Scanln(&code); err != nil { + return fmt.Errorf("failed to read access code; %w", err) + } + + gtsClient.Authentication, err = addAccessToken(gtsClient.Authentication, oauth2Conf, code) + if err != nil { + return fmt.Errorf("unable to get the access token; %w", err) + } + + account, err := gtsClient.VerifyCredentials() + if err != nil { + return fmt.Errorf("unable to verify the credentials; %w", err) + } + + loginName, err := config.SaveAuthentication(account.Username, gtsClient.Authentication) + if err != nil { + return fmt.Errorf("unable to save the authentication details; %w", err) + } + + fmt.Printf("Successfully logged into %s\n", loginName) + + return nil +} + +func authCodeURL(oauth2Conf oauth2.Config) string { + url := oauth2Conf.AuthCodeURL( + "state", + oauth2.AccessTypeOffline, + ) + "&client_name=" + internal.ApplicationName + + return url +} + +func addAccessToken(authentication config.Authentication, oauth2Conf oauth2.Config, code string) (config.Authentication, error) { + token, err := oauth2Conf.Exchange(context.Background(), code) + if err != nil { + return config.Authentication{}, fmt.Errorf("unable to exchange the code for an access token; %w", err) + } + + if token == nil || token.AccessToken == "" { + return config.Authentication{}, errEmptyAccessToken + } + + authentication.AccessToken = token.AccessToken + + return authentication, nil +} + +func openLink(url string) { + var open string + + if runtime.GOOS == "linux" { + open = "xdg-open" + } else { + return + } + + command := exec.Command(open, url) + + _ = command.Start() +} diff --git a/cmd/enbas/main.go b/cmd/enbas/main.go new file mode 100644 index 0000000..b8ffc20 --- /dev/null +++ b/cmd/enbas/main.go @@ -0,0 +1,75 @@ +package main + +import ( + "flag" + "fmt" + "os" +) + +type Executor interface { + Name() string + Parse([]string) error + Execute() error +} + +func main() { + if err := run(); err != nil { + fmt.Printf("ERROR: %v.\n", err) + os.Exit(1) + } +} + +func run() error { + const ( + login string = "login" + version string = "version" + show string = "show" + switchAccount string = "switch" + ) + + summaries := map[string]string{ + login: "login to an account on GoToSocial", + version: "print the application's version and build information", + show: "print details about a specified resource", + switchAccount: "switch to an account", + } + + flag.Usage = enbasUsageFunc(summaries) + + flag.Parse() + + if flag.NArg() < 1 { + flag.Usage() + + return nil + } + + 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]) + case show: + executor = newShowCommand(show, summaries[show]) + case switchAccount: + executor = newSwitchCommand(switchAccount, summaries[switchAccount]) + default: + flag.Usage() + return fmt.Errorf("unknown subcommand %q", subcommand) + } + + if err := executor.Parse(args); err != nil { + return fmt.Errorf("unable to parse the command line flags; %w", err) + } + + if err := executor.Execute(); err != nil { + return fmt.Errorf("received an error after executing %q; %w", executor.Name(), err) + } + + return nil +} diff --git a/cmd/enbas/show.go b/cmd/enbas/show.go new file mode 100644 index 0000000..dcd811a --- /dev/null +++ b/cmd/enbas/show.go @@ -0,0 +1,197 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "strings" + "unicode" + + "codeflow.dananglin.me.uk/apollo/enbas/internal/client" + "codeflow.dananglin.me.uk/apollo/enbas/internal/config" + "golang.org/x/net/html" +) + +var instanceDetailsFormat = `INSTANCE: + %s - %s + +DOMAIN: + %s + +VERSION: + Running GoToSocial %s + +CONTACT: + name: %s + username: %s + email: %s +` + +var accountDetailsFormat = ` +%s (@%s) + +ACCOUNT ID: + %s + +JOINED ON: + %s + +STATS: + Followers: %d + Following: %d + Statuses: %d + +BIOGRAPHY: + %s + +METADATA: %s + +ACCOUNT URL: + %s +` + +type showCommand struct { + *flag.FlagSet + targetType string + account string + myAccount bool +} + +func newShowCommand(name, summary string) *showCommand { + command := showCommand{ + FlagSet: flag.NewFlagSet(name, flag.ExitOnError), + targetType: "", + myAccount: false, + } + + command.StringVar(&command.targetType, "type", "", "specify the type of resource to display") + command.StringVar(&command.account, "account", "", "specify the account URI to lookup") + command.BoolVar(&command.myAccount, "my-account", false, "set to true to lookup your account") + command.Usage = commandUsageFunc(name, summary, command.FlagSet) + + return &command +} + +func (c *showCommand) Execute() error { + gtsClient, err := client.NewClientFromConfig() + if err != nil { + return fmt.Errorf("unable to create the GoToSocial client; %w", err) + } + + funcMap := map[string]func(*client.Client) error{ + "instance": c.showInstance, + "account": c.showAccount, + } + + doFunc, ok := funcMap[c.targetType] + if !ok { + return fmt.Errorf("unsupported type %q", c.targetType) + } + + return doFunc(gtsClient) +} + +func (c *showCommand) showInstance(gts *client.Client) error { + instance, err := gts.GetInstance() + if err != nil { + return fmt.Errorf("unable to retrieve the instance details; %w", err) + } + + fmt.Printf( + instanceDetailsFormat, + instance.Title, + instance.Description, + instance.Domain, + instance.Version, + instance.Contact.Account.DisplayName, + instance.Contact.Account.Username, + instance.Contact.Email, + ) + + return nil +} + +func (c *showCommand) showAccount(gts *client.Client) error { + var accountURI string + + if c.myAccount { + authConfig, err := config.NewAuthenticationConfigFromFile() + if err != nil { + return fmt.Errorf("unable to retrieve the authentication configuration; %w", err) + } + + accountURI = authConfig.CurrentAccount + } else { + if c.account == "" { + return errors.New("the account flag is not set") + } + + accountURI = c.account + } + + account, err := gts.GetAccount(accountURI) + if err != nil { + return fmt.Errorf("unable to retrieve the account details; %w", err) + } + + metadata := "" + + for _, field := range account.Fields { + metadata += fmt.Sprintf("\n %s: %s", field.Name, stripHTMLTags(field.Value)) + } + + fmt.Printf( + accountDetailsFormat, + account.DisplayName, + account.Username, + account.ID, + account.CreatedAt.Format("02 Jan 2006"), + account.FollowersCount, + account.FollowingCount, + account.StatusCount, + wrapLine(stripHTMLTags(account.Note), "\n ", 80), + metadata, + account.URL, + ) + + return nil +} + +func stripHTMLTags(text string) string { + token := html.NewTokenizer(strings.NewReader(text)) + + var builder strings.Builder + + for { + tt := token.Next() + switch tt { + case html.ErrorToken: + return builder.String() + case html.TextToken: + builder.WriteString(token.Token().Data + " ") + } + } +} + +func wrapLine(line, separator string, charLimit int) string { + if len(line) <= charLimit { + return line + } + + leftcursor, rightcursor := 0, 0 + + var builder strings.Builder + + for rightcursor < (len(line) - charLimit) { + rightcursor += charLimit + for !unicode.IsSpace(rune(line[rightcursor-1])) { + rightcursor-- + } + builder.WriteString(line[leftcursor:rightcursor] + separator) + leftcursor = rightcursor + } + + builder.WriteString(line[rightcursor:]) + + return builder.String() +} diff --git a/cmd/enbas/switch.go b/cmd/enbas/switch.go new file mode 100644 index 0000000..19155e4 --- /dev/null +++ b/cmd/enbas/switch.go @@ -0,0 +1,35 @@ +package main + +import ( + "flag" + "fmt" + + "codeflow.dananglin.me.uk/apollo/enbas/internal/config" +) + +type switchCommand struct { + *flag.FlagSet + toAccount string +} + +func newSwitchCommand(name, summary string) *switchCommand { + command := switchCommand{ + FlagSet: flag.NewFlagSet(name, flag.ExitOnError), + toAccount: "", + } + + command.StringVar(&command.toAccount, "to-account", "", "the account to switch to") + command.Usage = commandUsageFunc(name, summary, command.FlagSet) + + return &command +} + +func (c *switchCommand) Execute() error { + if err := config.UpdateCurrentAccount(c.toAccount); err != nil { + return fmt.Errorf("unable to switch accounts; %w", err) + } + + fmt.Printf("The current account is now set to %q.\n", c.toAccount) + + return nil +} diff --git a/cmd/enbas/usage.go b/cmd/enbas/usage.go new file mode 100644 index 0000000..660139b --- /dev/null +++ b/cmd/enbas/usage.go @@ -0,0 +1,76 @@ +package main + +import ( + "flag" + "fmt" + "slices" + "strings" +) + +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..f41c65c --- /dev/null +++ b/cmd/enbas/version.go @@ -0,0 +1,62 @@ +package main + +import ( + "flag" + "fmt" + "os" + "strings" +) + +var ( + binaryVersion string + buildTime string + goVersion string + gitCommit string +) + +type versionCommand struct { + *flag.FlagSet + 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, + showFullVersion: false, + } + + command.BoolVar(&command.showFullVersion, "full", false, "prints the full build information") + + command.Usage = commandUsageFunc(name, 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/go.mod b/go.mod index d01bc29..c2568ff 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,14 @@ -module enbas +module codeflow.dananglin.me.uk/apollo/enbas go 1.22.0 + +require ( + golang.org/x/net v0.21.0 + golang.org/x/oauth2 v0.17.0 +) + +require ( + github.com/golang/protobuf v1.5.3 // indirect + google.golang.org/appengine v1.6.7 // indirect + google.golang.org/protobuf v1.31.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..35081b1 --- /dev/null +++ b/go.sum @@ -0,0 +1,24 @@ +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/oauth2 v0.17.0 h1:6m3ZPmLEFdVxKKWnKq4VqZ60gutO35zm+zrAHVmHyDQ= +golang.org/x/oauth2 v0.17.0/go.mod h1:OzPDGQiuQMguemayvdylqddI7qcD9lnSDb+1FiwQ5HA= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= 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..672141c --- /dev/null +++ b/internal/client/client.go @@ -0,0 +1,125 @@ +package client + +import ( + "context" + "encoding/json" + "fmt" + "io" + "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 NewClientFromConfig() (*Client, error) { + config, err := config.NewAuthenticationConfigFromFile() + if err != nil { + return nil, fmt.Errorf("unable to get the authentication configuration; %w", err) + } + + currentAuthentication := config.Authentications[config.CurrentAccount] + + return NewClient(currentAuthentication), nil +} + +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 + + var account model.Account + + if err := g.sendRequest(http.MethodGet, url, nil, &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) GetInstance() (model.InstanceV2, error) { + path := "/api/v2/instance" + url := g.Authentication.Instance + path + + var instance model.InstanceV2 + + if err := g.sendRequest(http.MethodGet, url, nil, &instance); err != nil { + return model.InstanceV2{}, fmt.Errorf("received an error after sending the request to get the instance details; %w", err) + } + + return instance, nil +} + +func (g *Client) GetAccount(accountURI string) (model.Account, error) { + path := "/api/v1/accounts/lookup" + url := g.Authentication.Instance + path + "?acct=" + accountURI + + var account model.Account + + if err := g.sendRequest(http.MethodGet, url, nil, &account); err != nil { + return model.Account{}, fmt.Errorf("received an error after sending the request to get the account information; %w", err) + } + + return account, nil +} + +func (g *Client) sendRequest(method string, url string, requestBody io.Reader, object any) error { + ctx, cancel := context.WithTimeout(context.Background(), g.Timeout) + defer cancel() + + request, err := http.NewRequestWithContext(ctx, method, url, requestBody) + if err != nil { + return fmt.Errorf("unable to create the HTTP request, %w", err) + } + + 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/internal/client/register.go b/internal/client/register.go new file mode 100644 index 0000000..d2e36d4 --- /dev/null +++ b/internal/client/register.go @@ -0,0 +1,48 @@ +package client + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + + "codeflow.dananglin.me.uk/apollo/enbas/internal" + "codeflow.dananglin.me.uk/apollo/enbas/internal/model" +) + +type RegisterRequest struct { + ClientName string `json:"client_name"` + RedirectUris string `json:"redirect_uris"` + Scopes string `json:"scopes"` + Website string `json:"website"` +} + +func (g *Client) Register() error { + params := RegisterRequest{ + ClientName: internal.ApplicationName, + RedirectUris: internal.RedirectUri, + Scopes: "read write", + Website: internal.ApplicationWebsite, + } + + data, err := json.Marshal(params) + if err != nil { + return fmt.Errorf("unable to marshal the request body; %w", err) + } + + requestBody := bytes.NewBuffer(data) + + path := "/api/v1/apps" + url := g.Authentication.Instance + path + + var app model.Application + + if err := g.sendRequest(http.MethodPost, url, requestBody, &app); err != nil { + return fmt.Errorf("received an error after sending the registration request; %w", err) + } + + g.Authentication.ClientID = app.ClientID + g.Authentication.ClientSecret = app.ClientSecret + + return nil +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..d98538e --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,151 @@ +package config + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "codeflow.dananglin.me.uk/apollo/enbas/internal" +) + +type AuthenticationConfig struct { + CurrentAccount string `json:"currentAccount"` + Authentications map[string]Authentication `json:"authentications"` +} + +type Authentication struct { + Instance string `json:"instance"` + ClientID string `json:"clientId"` + ClientSecret string `json:"clientSecret"` + AccessToken string `json:"accessToken"` +} + +func SaveAuthentication(username string, authentication Authentication) (string, error) { + if err := ensureConfigDir(); err != nil { + return "", fmt.Errorf("unable to ensure the configuration directory; %w", err) + } + + var authConfig AuthenticationConfig + + filepath := authenticationConfigFile() + + if _, err := os.Stat(filepath); err != nil { + if !errors.Is(err, os.ErrNotExist) { + return "", fmt.Errorf("unknown error received when running stat on %s; %w", filepath, err) + } + + authConfig.Authentications = make(map[string]Authentication) + } else { + authConfig, err = NewAuthenticationConfigFromFile() + if err != nil { + return "", fmt.Errorf("unable to retrieve the existing authentication configuration; %w", err) + } + } + + instance := "" + + if strings.HasPrefix(authentication.Instance, "https://") { + instance = strings.TrimPrefix(authentication.Instance, "https://") + } else if strings.HasPrefix(authentication.Instance, "http://") { + instance = strings.TrimPrefix(authentication.Instance, "http://") + } + + authenticationName := username + "@" + instance + + authConfig.CurrentAccount = authenticationName + + authConfig.Authentications[authenticationName] = authentication + + if err := saveAuthenticationFile(authConfig); err != nil { + return "", fmt.Errorf("unable to save the authentication configuration to file; %w", err) + } + + return authenticationName, nil +} + +func NewAuthenticationConfigFromFile() (AuthenticationConfig, error) { + path := authenticationConfigFile() + + file, err := os.Open(path) + if err != nil { + return AuthenticationConfig{}, fmt.Errorf("unable to open %s, %w", path, err) + } + defer file.Close() + + var authConfig AuthenticationConfig + + if err := json.NewDecoder(file).Decode(&authConfig); err != nil { + return AuthenticationConfig{}, fmt.Errorf("unable to decode the JSON data; %w", err) + } + + return authConfig, nil +} + +func UpdateCurrentAccount(account string) error { + authConfig, err := NewAuthenticationConfigFromFile() + if err != nil { + return fmt.Errorf("unable to retrieve the existing authentication configuration; %w", err) + } + + if _, ok := authConfig.Authentications[account]; !ok { + return fmt.Errorf("account %s is not found", account) + } + + authConfig.CurrentAccount = account + + if err := saveAuthenticationFile(authConfig); err != nil { + return fmt.Errorf("unable to save the authentication configuration to file; %w", err) + } + + return nil +} + +func authenticationConfigFile() string { + return filepath.Join(configDir(), "authentications.json") +} + +func configDir() string { + rootDir, err := os.UserConfigDir() + if err != nil { + rootDir = "." + } + + return filepath.Join(rootDir, internal.ApplicationName) +} + +func ensureConfigDir() error { + dir := configDir() + + if _, err := os.Stat(dir); err != nil { + if errors.Is(err, os.ErrNotExist) { + if err := os.MkdirAll(dir, 0o750); err != nil { + return fmt.Errorf("unable to create %s; %w", dir, err) + } + } else { + return fmt.Errorf("unknown error received when running stat on %s; %w", dir, err) + } + } + + return nil +} + +func saveAuthenticationFile(authConfig AuthenticationConfig) error { + file, err := os.Create(authenticationConfigFile()) + if err != nil { + return fmt.Errorf("unable to open the config file; %w", err) + } + + defer file.Close() + + encoder := json.NewEncoder(file) + encoder.SetIndent("", " ") + + if err := encoder.Encode(authConfig); err != nil { + return fmt.Errorf("unable to save the JSON data to the authentication config file; %w", err) + } + + return nil +} 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/internal/model/account.go b/internal/model/account.go new file mode 100644 index 0000000..6dc9b72 --- /dev/null +++ b/internal/model/account.go @@ -0,0 +1,52 @@ +package model + +import "time" + +type Account struct { + Acct string `json:"acct"` + Avatar string `json:"avatar"` + AvatarStatic string `json:"avatar_static"` + Bot bool `json:"bot"` + CreatedAt time.Time `json:"created_at"` + CustomCSS string `json:"custom_css"` + Discoverable bool `json:"discoverable"` + DisplayName string `json:"display_name"` + Emojis []Emoji `json:"emojis"` + EnableRSS bool `json:"enable_rss"` + Fields []Field `json:"fields"` + FollowersCount int `json:"followers_count"` + FollowingCount int `json:"following_count"` + Header string `json:"header"` + HeaderStatic string `json:"header_static"` + ID string `json:"id"` + LastStatusAt string `json:"last_status_at"` + Locked bool `json:"locked"` + MuteExpiresAt time.Time `json:"mute_expires_at"` + Note string `json:"note"` + Role AccountRole `json:"role"` + Source Source `json:"source"` + StatusCount int `json:"statuses_count"` + Suspended bool `json:"suspended"` + URL string `json:"url"` + Username string `json:"username"` +} + +type AccountRole struct { + Name string `json:"name"` +} + +type Source struct { + Fields []Field `json:"fields"` + FollowRequestCount int `json:"follow_requests_count"` + Language string `json:"language"` + Note string `json:"note"` + Privacy string `json:"string"` + Sensitive bool `json:"sensitive"` + StatusContentType string `json:"status_content_type"` +} + +type Field struct { + Name string `json:"name"` + Value string `json:"value"` + VerifiedAt string `json:"verified_at"` +} diff --git a/internal/model/application.go b/internal/model/application.go new file mode 100644 index 0000000..3b7a674 --- /dev/null +++ b/internal/model/application.go @@ -0,0 +1,11 @@ +package model + +type Application struct { + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + ID string `json:"id"` + Name string `json:"name"` + RedirectUri string `json:"redirect_uri"` + VapidKey string `json:"vapid_key"` + Website string `json:"website"` +} diff --git a/internal/model/emoji.go b/internal/model/emoji.go new file mode 100644 index 0000000..a71f31c --- /dev/null +++ b/internal/model/emoji.go @@ -0,0 +1,9 @@ +package model + +type Emoji struct { + Category string `json:"category"` + Shortcode string `json:"shortcode"` + StaticURL string `json:"static_url"` + URL string `json:"url"` + VisibleInPicker bool `json:"visible_in_picker"` +} diff --git a/internal/model/instance_v2.go b/internal/model/instance_v2.go new file mode 100644 index 0000000..7ade86a --- /dev/null +++ b/internal/model/instance_v2.go @@ -0,0 +1,106 @@ +package model + +type InstanceV2 struct { + AccountDomain string `json:"account_domain"` + Configuration InstanceConfiguration `json:"configuration"` + Contact InstanceV2Contact `json:"contact"` + Description string `json:"description"` + Domain string `json:"domain"` + Languages []string `json:"languages"` + Registrations InstanceV2Registrations `json:"registrations"` + Rules []InstanceRule `json:"rules"` + SourceURL string `json:"source_url"` + Terms string `json:"terms"` + Thumbnail InstanceV2Thumbnail `json:"thumbnail"` + Title string `json:"title"` + Usage InstanceV2Usage `json:"usage"` + Version string `json:"version"` +} + +type InstanceConfiguration struct { + Accounts InstanceConfigurationAccounts `json:"accounts"` + Emojis InstanceConfigurationEmojis `json:"emojis"` + MediaAttachments InstanceConfigurationMediaAttachments `json:"media_attachments"` + Polls InstanceConfigurationPolls `json:"polls"` + Statuses InstanceConfigurationStatuses `json:"statuses"` + Translation InstanceV2ConfigurationTranslation `json:"translation"` + URLs InstanceV2URLs `json:"urls"` +} + +type InstanceConfigurationAccounts struct { + AllowCustomCSS bool `json:"allow_custom_css"` + MaxFeaturedTags int `json:"max_featured_tags"` + MaxProfileFields int `json:"max_profile_fields"` +} + +type InstanceConfigurationEmojis struct { + EmojiSizeLimit int `json:"emoji_size_limit"` +} + +type InstanceConfigurationMediaAttachments struct { + ImageMatrixLimit int `json:"image_matrix_limit"` + ImageSizeLimit int `json:"image_size_limit"` + SupportedMimeTypes []string `json:"supported_mime_types"` + VideoFrameRateLimit int `json:"video_frame_rate_limit"` + VideoMatrixLimit int `json:"video_matrix_limit"` + VideoSizeLimit int `json:"video_size_limit"` +} + +type InstanceConfigurationPolls struct { + MaxCharactersPerOption int `json:"max_characters_per_option"` + MaxExpiration int `json:"max_expiration"` + MaxOptions int `json:"max_options"` + MinExpiration int `json:"min_expiration"` +} + +type InstanceConfigurationStatuses struct { + CharactersReservedPerURL int `json:"characters_reserved_per_url"` + MaxCharacters int `json:"max_characters"` + MaxMediaAttachments int `json:"max_media_attachments"` + SupportedMimeTypes []string `json:"supported_mime_types"` +} + +type InstanceV2ConfigurationTranslation struct { + Enabled bool `json:"enabled"` +} + +type InstanceV2URLs struct { + Streaming string `json:"streaming"` +} + +type InstanceV2Contact struct { + Account Account `json:"account"` + Email string `json:"email"` +} + +type InstanceV2Registrations struct { + ApprovalRequired bool `json:"approval_required"` + Enabled bool `json:"enabled"` + Message string `json:"message"` +} + +type InstanceRule struct { + ID string `json:"id"` + Text string `json:"text"` +} + +type InstanceV2Thumbnail struct { + BlurHash string `json:"blurhash"` + ThumbnailDescription string `json:"thumbnail_description"` + ThumbnailType string `json:"thumbnail_type"` + URL string `json:"url"` + Version InstanceV2ThumbnailVersions `json:"versions"` +} + +type InstanceV2ThumbnailVersions struct { + Size1URL string `json:"@1x"` + Size2URL string `json:"@2x"` +} + +type InstanceV2Usage struct { + Users InstanceV2Users `json:"users"` +} + +type InstanceV2Users struct { + ActiveMonth int `json:"active_month"` +} diff --git a/internal/model/status.go b/internal/model/status.go new file mode 100644 index 0000000..99f3d34 --- /dev/null +++ b/internal/model/status.go @@ -0,0 +1,148 @@ +package model + +import "time" + +type Status struct { + Account Account `json:"account"` + Application Application `json:"application"` + Bookmarked bool `json:"bookmarked"` + Card Card `json:"card"` + Content string `json:"content"` + CreatedAt time.Time `json:"created_at"` + Emojis []Emoji `json:"emojis"` + Favourited bool `json:"favourited"` + FavouritesCount int `json:"favourites_count"` + ID string `json:"id"` + InReplyToAccountID string `json:"in_reply_to_account_id"` + InReplyToID string `json:"in_reply_to_id"` + Language string `json:"language"` + MediaAttachments []Attachment `json:"media_attachments"` + Mentions []Mention `json:"mentions"` + Muted bool `json:"muted"` + Pinned bool `json:"pinned"` + Poll Poll `json:"poll"` + Reblog StatusReblogged `json:"reblog"` + Reblogged bool `json:"reblogged"` + RebloggsCount int `json:"reblogs_count"` + RepliesCount int `json:"replies_count"` + Sensitive bool `json:"sensitive"` + SpolierText string `json:"spoiler_text"` + Tags []Tag `json:"tags"` + Text string `json:"text"` + URI string `json:"uri"` + URL string `json:"url"` + Visibility string `json:"visibility"` +} + +type Card struct { + AuthorName string `json:"author_name"` + AuthorURL string `json:"author_url"` + Blurhash string `json:"blurhash"` + Description string `json:"description"` + EmbedURL string `json:"embed_url"` + HTML string `json:"html"` + Image string `json:"image"` + ProviderName string `json:"provider_name"` + ProviderURL string `json:"provider_url"` + Title string `json:"title"` + Type string `json:"type"` + URL string `json:"url"` + Height int `json:"height"` + Width int `json:"width"` +} + +type Mention struct { + Acct string `json:"acct"` + ID string `json:"id"` + URL string `json:"url"` + Username string `json:"username"` +} + +type Poll struct { + Emojis []Emoji `json:"emojis"` + Expired bool `json:"expired"` + Voted bool `json:"voted"` + Multiple bool `json:"multiple"` + ExpiredAt time.Time `json:"expires_at"` + ID string `json:"id"` + OwnVotes []int `json:"own_votes"` + VotersCount int `json:"voters_count"` + VotesCount int `json:"votes_count"` + Options []PollOption `json:"options"` +} + +type PollOption struct { + Title string `json:"title"` + VotesCount string `json:"votes_count"` +} + +type StatusReblogged struct { + Account Account `json:"account"` + Application Application `json:"application"` + Bookmarked bool `json:"bookmarked"` + Card Card `json:"card"` + Content string `json:"content"` + CreatedAt time.Time `json:"created_at"` + Emojis []Emoji `json:"emojis"` + Favourited bool `json:"favourited"` + FavouritesCount int `json:"favourites_count"` + ID string `json:"id"` + InReplyToAccountID string `json:"in_reply_to_account_id"` + InReplyToID string `json:"in_reply_to_id"` + Language string `json:"language"` + MediaAttachments []Attachment `json:"media_attachments"` + Mentions []Mention `json:"mentions"` + Muted bool `json:"muted"` + Pinned bool `json:"pinned"` + Poll Poll `json:"poll"` + Reblogged bool `json:"reblogged"` + RebloggsCount int `json:"reblogs_count"` + RepliesCount int `json:"replies_count"` + Sensitive bool `json:"sensitive"` + SpolierText string `json:"spoiler_text"` + Tags []Tag `json:"tags"` + Text string `json:"text"` + URI string `json:"uri"` + URL string `json:"url"` + Visibility string `json:"visibility"` +} + +type Tag struct { + History []any `json:"history"` + Name string `json:"name"` + URL string `json:"url"` +} + +type Attachment struct { + Meta MediaMeta `json:"meta"` + Blurhash string `json:"blurhash"` + Description string `json:"description"` + ID string `json:"id"` + PreviewRemoteURL string `json:"preview_remote_url"` + PreviewURL string `json:"preview_url"` + RemoteURL string `json:"remote_url"` + TextURL string `json:"text_url"` + Type string `json:"type"` + URL string `json:"url"` +} + +type MediaMeta struct { + Focus MediaFocus `json:"focus"` + Original MediaDimensions `json:"original"` + Small MediaDimensions `json:"small"` +} + +type MediaFocus struct { + X float64 `json:"x"` + Y float64 `json:"y"` +} + +type MediaDimensions struct { + Aspect float64 `json:"aspect"` + Bitrate int `json:"bitrate"` + Duration float64 `json:"duration"` + FrameRate string `json:"frame_rate"` + Size string `json:"size"` + Height int `json:"height"` + Width int `json:"width"` +} diff --git a/main.go b/main.go deleted file mode 100644 index 55d03a6..0000000 --- a/main.go +++ /dev/null @@ -1,7 +0,0 @@ -package main - -import "fmt" - -func main() { - fmt.Println("Hello, Enbas!") -}