From: signal9 Date: Sat, 17 Dec 2022 00:38:14 +0000 (+0000) Subject: Begin setting up commands thru login (#5) X-Git-Url: https://git.vexinglabs.com/?a=commitdiff_plain;h=197cd81086de66968f24412880a32d59470250c3;p=dead-tooter.git Begin setting up commands thru login (#5) * 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 Reviewed-on: https://git.vexingworkshop.com/signal9/dead-tooter/pulls/5 --- diff --git a/README.md b/README.md index 04e835f..35b30ec 100644 --- 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 index 0000000..31c3d12 --- /dev/null +++ b/cmd/application.go @@ -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 index 0000000..1842764 --- /dev/null +++ b/cmd/login.go @@ -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 index 0000000..bb129bf --- /dev/null +++ b/cmd/root.go @@ -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 index 0000000..7e0a76e --- /dev/null +++ b/cmd/version.go @@ -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 85301b8..e889d70 100644 --- 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 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 d030fd8..8975bc7 100644 --- 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() } diff --git a/pkg/mastodon/account.go b/pkg/mastodon/account.go index 34c5ffd..130fc9e 100644 --- a/pkg/mastodon/account.go +++ b/pkg/mastodon/account.go @@ -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 index 69d8898..0000000 --- a/pkg/mastodon/app.go +++ /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 -} diff --git a/pkg/mastodon/application.go b/pkg/mastodon/application.go index b425047..6d4e2bb 100644 --- a/pkg/mastodon/application.go +++ b/pkg/mastodon/application.go @@ -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 index 0000000..6f12a81 --- /dev/null +++ b/pkg/mastodon/oauth.go @@ -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 +}