]> Vexing Labs - dead-tooter.git/commitdiff
Begin setting up commands thru login (#5)
authorsignal9 <signal9@noreply.git.vexingworkshop.com>
Sat, 17 Dec 2022 00:38:14 +0000 (00:38 +0000)
committersignal9 <signal9@noreply.git.vexingworkshop.com>
Sat, 17 Dec 2022 00:38:14 +0000 (00:38 +0000)
* Remove App struct and methods in favor of Client struct and bare functions.
* Introduce Cobra cli framework
* Add version command
* Add login command

Co-authored-by: Adam Shamblin <adam@vexingworkshop.com>
Reviewed-on: https://git.vexingworkshop.com/signal9/dead-tooter/pulls/5

12 files changed:
README.md
cmd/application.go [new file with mode: 0644]
cmd/login.go [new file with mode: 0644]
cmd/root.go [new file with mode: 0644]
cmd/version.go [new file with mode: 0644]
go.mod
go.sum [new file with mode: 0644]
main.go
pkg/mastodon/account.go
pkg/mastodon/app.go [deleted file]
pkg/mastodon/application.go
pkg/mastodon/oauth.go [new file with mode: 0644]

index 04e835f14e21cad6aec2df8b98ce3b93a835701f..35b30ecf48329e8f70e2156135f580d67dfc5012 100644 (file)
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@ There are a lot of people who have moved to the Fediverse from Twitter, and for
 some this is very exciting.
 
 The increase in volume has affected the administrators more than anyone, no
-doubht, but the users have also been affected. While Mastodon's filters and feed
+doubt, but the users have also been affected. While Mastodon's filters and feed
 settings are exceedingly powerful, there are a few more things I'd like to be
 able to do with my Fedi data.
 
@@ -15,3 +15,9 @@ able to do with my Fedi data.
 * Delete follows/followers in bulk
   * by account age
   * by age of last post
+
+## Usage
+
+```
+dead-tooter login --host hackers.town
+```
diff --git a/cmd/application.go b/cmd/application.go
new file mode 100644 (file)
index 0000000..31c3d12
--- /dev/null
@@ -0,0 +1,15 @@
+package tooter
+
+import "github.com/spf13/cobra"
+
+func init() {
+       rootCmd.AddCommand(cmdApp)
+}
+
+var cmdApp = &cobra.Command{
+       Use:   "application",
+       Short: "Register and perform application-level actions",
+       Long:  "application, man",
+       Run: func(cmd *cobra.Command, args []string) {
+       },
+}
diff --git a/cmd/login.go b/cmd/login.go
new file mode 100644 (file)
index 0000000..1842764
--- /dev/null
@@ -0,0 +1,64 @@
+package tooter
+
+import (
+       "git.vexingworkshop.com/signal9/dead-tooter/pkg/mastodon"
+       "github.com/spf13/cobra"
+)
+
+var host string
+
+func init() {
+       loginCmd.Flags().StringVarP(&host,
+               "host", "H", "", "Mastodon host where your account lives.")
+       loginCmd.MarkFlagRequired("host")
+
+       rootCmd.AddCommand(loginCmd)
+}
+
+var loginCmd = &cobra.Command{
+       Use:   "login",
+       Short: "Login to Mastodon",
+       Long:  "Initiate login to your Mastodon server of choice",
+       Run: func(cmd *cobra.Command, args []string) {
+               login()
+       },
+}
+
+func login() {
+       app, err := mastodon.Load("dead-tooter")
+       if err != nil {
+               client := mastodon.Client{
+                       ClientName:   "dead-tooter",
+                       RedirectUris: mastodon.RedirectUris,
+                       Scopes:       "read write follow push",
+                       Website:      "https://dead-tooter.vexingworkshop.com",
+               }
+
+               app, err = mastodon.Create(host, client)
+               if err != nil {
+                       panic(err.Error())
+               }
+
+               err = app.Save()
+               if err != nil {
+                       panic(err.Error())
+               }
+       }
+
+       var account mastodon.Account
+       code := account.Authorize(host, app)
+       token, err = account.RequestToken(host, app, code)
+       if err != nil {
+               panic(err.Error())
+       }
+
+       err = token.Save()
+       if err != nil {
+               panic(err.Error())
+       }
+
+       err = account.VerifyCredentials(host, token)
+       if err != nil {
+               panic(err.Error())
+       }
+}
diff --git a/cmd/root.go b/cmd/root.go
new file mode 100644 (file)
index 0000000..bb129bf
--- /dev/null
@@ -0,0 +1,44 @@
+package tooter
+
+import (
+       "fmt"
+       "os"
+
+       "git.vexingworkshop.com/signal9/dead-tooter/pkg/mastodon"
+       "github.com/spf13/cobra"
+)
+
+var token mastodon.Token
+
+var rootCmd = &cobra.Command{
+       Use:   "dead-tooter",
+       Short: "A CLI for Mastodon hate scripts",
+       Long: `Provides a collection of capabilities that may or may not
+       be present in the Mastodon web UI`,
+
+       PersistentPreRun: func(cmd *cobra.Command, args []string) {
+               var err error
+               token, err = mastodon.LoadToken()
+               if err != nil {
+                       fmt.Println("No authentication token found.\nYou must login before performing futher commands.")
+               }
+       },
+
+       Run: func(cmd *cobra.Command, args []string) {
+               fmt.Println("d34d-t00t3r")
+       },
+
+       PersistentPostRun: func(cmd *cobra.Command, args []string) {
+               err := token.Save()
+               if err != nil {
+                       panic(err.Error())
+               }
+       },
+}
+
+func Execute() {
+       if err := rootCmd.Execute(); err != nil {
+               fmt.Println(err)
+               os.Exit(1)
+       }
+}
diff --git a/cmd/version.go b/cmd/version.go
new file mode 100644 (file)
index 0000000..7e0a76e
--- /dev/null
@@ -0,0 +1,20 @@
+package tooter
+
+import (
+       "fmt"
+
+       "github.com/spf13/cobra"
+)
+
+func init() {
+       rootCmd.AddCommand(versionCmd)
+}
+
+var versionCmd = &cobra.Command{
+       Use:   "version",
+       Short: "Print the version number of d34d-t00ter",
+       Long:  "Version, man, version!",
+       Run: func(cmd *cobra.Command, args []string) {
+               fmt.Println("d34d-t00ter, Mastodon hate CLI v0.1")
+       },
+}
diff --git a/go.mod b/go.mod
index 85301b890183732b4d784c9e1b4baa3c8ba3c5cb..e889d70087cfc7f892cb5d9cb80b62219135d6c0 100644 (file)
--- a/go.mod
+++ b/go.mod
@@ -1,3 +1,10 @@
 module git.vexingworkshop.com/signal9/dead-tooter
 
 go 1.18
+
+require github.com/spf13/cobra v1.6.1
+
+require (
+       github.com/inconshreveable/mousetrap v1.0.1 // indirect
+       github.com/spf13/pflag v1.0.5 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644 (file)
index 0000000..442875a
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,10 @@
+github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
+github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc=
+github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/spf13/cobra v1.6.1 h1:o94oiPyS4KD1mPy2fmcYYHHfCxLqYjJOhGsCHFZtEzA=
+github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY=
+github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
+github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/main.go b/main.go
index d030fd8e9815b13f95822d4da600ba376f60fae9..8975bc770740c3c6b1c44e640bd4d4ac2c9e56da 100644 (file)
--- a/main.go
+++ b/main.go
@@ -1,44 +1,9 @@
 package main
 
 import (
-       "fmt"
-
-       "git.vexingworkshop.com/signal9/dead-tooter/pkg/mastodon"
+       cmd "git.vexingworkshop.com/signal9/dead-tooter/cmd"
 )
 
 func main() {
-       app := mastodon.Application{}
-       err := app.Load("dead-tooter")
-       if err != nil {
-               panic(err.Error())
-       }
-
-       token, err := app.Login()
-       if err != nil {
-               panic(err.Error())
-       }
-
-       err = app.VerifyCredentials(token)
-       if err != nil {
-               panic(err.Error())
-       }
-
-       var account mastodon.Account
-       code := account.Authorize(app)
-       token, err = account.RequestToken(app, code)
-       if err != nil {
-               panic(err.Error())
-       }
-
-       err = account.VerifyCredentials(token)
-       if err != nil {
-               panic(err.Error())
-       }
-
-       followers, err := account.GetFollowers(token)
-       if err != nil {
-               panic(err.Error())
-       }
-
-       fmt.Printf("%+v\n", followers)
+       cmd.Execute()
 }
index 34c5ffd2c0b6cf3b08f6d0e4d8e81a6f85ca2388..130fc9ed96948dc7ee38f16ffcf7f219e95662f4 100644 (file)
@@ -10,6 +10,7 @@ import (
        "os/exec"
 )
 
+// Account represents a user of Mastodon and their associated profile.
 type Account struct {
        ID             string  `json:"id"`
        UserName       string  `json:"username"`
@@ -33,6 +34,8 @@ type Account struct {
        Fields         []Field `json:"fields"`
 }
 
+// Source is an extra attribute that contains source values to be used with API
+// methods that verify and update credentials.
 type Source struct {
        Privacy             string  `json:"privacy"`
        Sensitive           bool    `json:"sensitive"`
@@ -42,27 +45,31 @@ type Source struct {
        FollowRequestsCount int     `json:"follow_requests_count"`
 }
 
+// Field stores custom field data on the user account.
 type Field struct {
        Name       string `json:"name"`
        Value      string `json:"value"`
        VerifiedAt string `json:"verified_at"`
 }
 
+// Emoji is the emoji portion of the user account.
 type Emoji struct {
        ShortCode       string `json:"shortcode"`
-       Url             string `json:"url"`
-       StaticUrl       string `json:"static_url"`
+       URL             string `json:"url"`
+       StaticURL       string `json:"static_url"`
        VisibleInPicker bool   `json:"visible_in_picker"`
 }
 
-func (a *Account) Authorize(app Application) (code string) {
+// Authorize opens the default browser to initiate the authorization flow
+// for the current user.
+func (a *Account) Authorize(host string, app Application) (code string) {
        v := url.Values{}
        v.Set("client_id", app.ClientID)
        v.Set("response_type", "code")
        v.Set("redirect_uri", RedirectUris)
 
        u := url.URL{
-               Host:     mastohost,
+               Host:     host,
                Path:     "oauth/authorize",
                RawQuery: v.Encode(),
        }
@@ -82,7 +89,12 @@ func (a *Account) Authorize(app Application) (code string) {
        return
 }
 
-func (a *Account) RequestToken(app Application, code string) (token Token, err error) {
+// RequestToken takes the provided authorization code and returns a structure
+// containing a bearer token necessary to make future, authenticated requests
+// on the part of the user.
+func (a *Account) RequestToken(
+       host string, app Application, code string) (token Token, err error) {
+
        v := url.Values{}
        v.Set("client_id", app.ClientID)
        v.Set("client_secret", app.ClientSecret)
@@ -91,9 +103,8 @@ func (a *Account) RequestToken(app Application, code string) (token Token, err e
        v.Set("grant_type", "authorization_code")
 
        u := url.URL{
-               Host:     mastohost,
-               Path:     "oauth/token",
-               RawQuery: v.Encode(),
+               Host: host,
+               Path: "oauth/token",
        }
        u.Scheme = "https"
 
@@ -120,11 +131,13 @@ func (a *Account) RequestToken(app Application, code string) (token Token, err e
        return
 }
 
-func (a *Account) VerifyCredentials(token Token) (err error) {
+// VerifyCredentials hydrates the account object by validating the bearer token
+// against the Mastodon API
+func (a *Account) VerifyCredentials(host string, token Token) (err error) {
        client := &http.Client{}
 
        u := url.URL{
-               Host: mastohost,
+               Host: host,
                Path: "api/v1/accounts/verify_credentials",
        }
        u.Scheme = "https"
@@ -158,7 +171,10 @@ func (a *Account) VerifyCredentials(token Token) (err error) {
        return
 }
 
-func (a *Account) GetFollowers(token Token) (followers []Account, err error) {
+// GetFollowers returns a list of all accounts following the logged in user
+func (a *Account) GetFollowers(
+       host string, token Token) (followers []Account, err error) {
+
        client := &http.Client{}
 
        id := url.PathEscape(a.ID)
@@ -168,7 +184,7 @@ func (a *Account) GetFollowers(token Token) (followers []Account, err error) {
        }
 
        u := url.URL{
-               Host: mastohost,
+               Host: host,
                Path: path,
        }
        u.Scheme = "https"
diff --git a/pkg/mastodon/app.go b/pkg/mastodon/app.go
deleted file mode 100644 (file)
index 69d8898..0000000
+++ /dev/null
@@ -1,50 +0,0 @@
-package mastodon
-
-import (
-       "encoding/json"
-       "io"
-       "net/http"
-       "net/url"
-)
-
-// RedirectUris when passed to the redirect_uris parameter, will
-// show return the authorization code instead of redirecting the client.
-const RedirectUris = "urn:ietf:wg:oauth:2.0:oob"
-
-// App represents the basic and methods necessary to register an application
-// with a Mastodon server.
-type App struct {
-       ClientName   string `json:"client_name"`
-       RedirectUris string `json:"redirect_uris"`
-       Scopes       string `json:"scopes"`
-       Website      string `json:"website"`
-}
-
-// Create calls the Mastodon API to register the App. It returns an Application
-// instance.
-//
-//     app, err := mastodon.App{
-//             ClientName:   "dead-tooter",
-//             RedirectUris: mastodon.RedirectUris,
-//             Scopes:       "read write follow push",
-//             Website:      "https://dead-tooter.vexingworkshop.com",
-//     }.Create()
-func (a *App) Create() (application Application, err error) {
-       resp, err := http.PostForm("https://hackers.town/api/v1/apps",
-               url.Values{
-                       "client_name":   {a.ClientName},
-                       "redirect_uris": {a.RedirectUris},
-                       "scopes":        {a.Scopes},
-                       "website":       {a.Website},
-               },
-       )
-       if err != nil {
-               panic(err.Error())
-       }
-       defer resp.Body.Close()
-
-       body, err := io.ReadAll(resp.Body)
-       err = json.Unmarshal(body, &application)
-
-       return
-}
index b4250471a789f4dd81de017f94d4a84964321a79..6d4e2bb098b5aa27161353d67cf30d3942674f87 100644 (file)
@@ -9,15 +9,17 @@ import (
        "os"
 )
 
-const configdir = "foodir/"
-const mastohost = "hackers.town"
-
-// Token struct contains the data returned by the Application login request.
-type Token struct {
-       AccessToken string `json:"access_token"`
-       TokenType   string `json:"token_type"`
-       Scope       string `json:"scope"`
-       CreatedAt   int    `json:"created_at"`
+const ConfigDir = "foodir/"
+
+// RedirectUris when passed to the redirect_uris parameter, will
+// show return the authorization code instead of redirecting the client.
+const RedirectUris = "urn:ietf:wg:oauth:2.0:oob"
+
+// APIError contains the application specific error message returned
+// by the Mastodon API.
+type APIError struct {
+       Error            string `json:"error"`
+       ErrorDescription string `json:"error_description"`
 }
 
 // Application represents the data and functions that establish a
@@ -32,35 +34,79 @@ type Application struct {
        VapidKey     string `json:"vapid_key,omitempty"`
 }
 
-// Save will store a serialized version of the Application struct to a file.
-func (a *Application) Save() {
-       err := os.Mkdir(configdir, 0750)
-       if err != nil && !os.IsExist(err) {
-               panic(err.Error())
+// Client represents the basic stanza required to register an application
+// with the Mastodon API
+type Client struct {
+       ClientName   string `json:"client_name"`
+       RedirectUris string `json:"redirect_uris"`
+       Scopes       string `json:"scopes"`
+       Website      string `json:"website"`
+}
+
+// Create calls the Mastodon API to regsiter the application.
+func Create(host string, client Client) (app Application, err error) {
+       v := url.Values{}
+       v.Set("client_name", client.ClientName)
+       v.Set("redirect_uris", client.RedirectUris)
+       v.Set("scopes", client.Scopes)
+       v.Set("website", client.Website)
+
+       u := url.URL{
+               Host: host,
+               Path: "api/v1/apps",
        }
+       u.Scheme = "https"
 
-       data, err := json.Marshal(a)
+       resp, err := http.PostForm(u.String(), v)
        if err != nil {
-               panic(err.Error())
+               return
+       }
+       if resp.StatusCode != 200 {
+               err = errors.New(resp.Status)
+               return
        }
+       defer resp.Body.Close()
 
-       err = os.WriteFile(configdir+a.Name, data, 0666)
+       body, err := io.ReadAll(resp.Body)
        if err != nil {
-               panic(err.Error())
+               return
        }
+       err = json.Unmarshal(body, &app)
+
+       return
 }
 
 // Load will hydrate an Application instance based upon data stored in
 // a file.
-func (a *Application) Load(name string) error {
-       data, err := os.ReadFile(configdir + name)
+func Load(name string) (app Application, err error) {
+       data, err := os.ReadFile(ConfigDir + name)
        if err != nil && os.IsNotExist(err) {
-               panic(err.Error())
+               return
+       }
+
+       err = json.Unmarshal(data, &app)
+       if err != nil {
+               return
+       }
+
+       return app, nil
+}
+
+// Save will store a serialized version of the Application struct to a file.
+func (a *Application) Save() (err error) {
+       err = os.Mkdir(ConfigDir, 0750)
+       if err != nil && !os.IsExist(err) {
+               return
+       }
+
+       data, err := json.Marshal(a)
+       if err != nil {
+               return
        }
 
-       err = json.Unmarshal(data, a)
+       err = os.WriteFile(ConfigDir+a.Name, data, 0666)
        if err != nil {
-               panic(err.Error())
+               return
        }
 
        return nil
@@ -68,7 +114,7 @@ func (a *Application) Load(name string) error {
 
 // Login authenticates the application to the Mastodon API, returning
 // a bearer token to be used with future requests.
-func (a *Application) Login() (token Token, err error) {
+func (a *Application) Login(host string) (token Token, err error) {
        v := url.Values{}
        v.Set("client_id", a.ClientID)
        v.Set("client_secret", a.ClientSecret)
@@ -76,7 +122,7 @@ func (a *Application) Login() (token Token, err error) {
        v.Set("grant_type", "client_credentials")
 
        u := url.URL{
-               Host:     mastohost,
+               Host:     host,
                Path:     "oauth/token",
                RawQuery: v.Encode(),
        }
@@ -108,11 +154,11 @@ func (a *Application) Login() (token Token, err error) {
 
 // VerifyCredentials accepts a Token object and validates the contained
 // token against the Mastodon API.
-func (a *Application) VerifyCredentials(token Token) (err error) {
+func (a *Application) VerifyCredentials(host string, token Token) (err error) {
        client := &http.Client{}
 
        u := url.URL{
-               Host: mastohost,
+               Host: host,
                Path: "api/v1/apps/verify_credentials",
        }
        u.Scheme = "https"
diff --git a/pkg/mastodon/oauth.go b/pkg/mastodon/oauth.go
new file mode 100644 (file)
index 0000000..6f12a81
--- /dev/null
@@ -0,0 +1,45 @@
+package mastodon
+
+import (
+       "encoding/json"
+       "os"
+)
+
+// Token struct contains the data returned by the Application login request.
+type Token struct {
+       AccessToken string `json:"access_token"`
+       TokenType   string `json:"token_type"`
+       Scope       string `json:"scope"`
+       CreatedAt   int    `json:"created_at"`
+}
+
+// LoadToken deserializes the application authentication token from disc, if it
+// exists.
+func LoadToken() (token Token, err error) {
+       data, err := os.ReadFile(ConfigDir + "/token")
+       if err != nil && os.IsNotExist(err) {
+               return
+       }
+
+       err = json.Unmarshal(data, &token)
+       if err != nil {
+               return
+       }
+
+       return
+}
+
+// Save serializes the application token to disk.
+func (t *Token) Save() (err error) {
+       data, err := json.Marshal(t)
+       if err != nil {
+               return
+       }
+
+       err = os.WriteFile(ConfigDir+"/token", data, 0666)
+       if err != nil {
+               return
+       }
+
+       return
+}