diff --git a/.gitignore b/.gitignore index 722d5e7..adfefb3 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .vscode +dist/ diff --git a/client/authorization.go b/client/authorization.go new file mode 100644 index 0000000..014f04d --- /dev/null +++ b/client/authorization.go @@ -0,0 +1,123 @@ +package client + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/mattn/go-mastodon" + "github.com/pkg/browser" + log "github.com/sirupsen/logrus" +) + +func RegisterNewClient(serverName string) (*mastodon.Client, error) { + done := make(chan os.Signal, 1) + + // Start temporary server to capture auth code + var authCode string + + // Listen on default port + listener := *newListener() + listenerPort := listener.Addr().(*net.TCPAddr).Port + listenerHost := fmt.Sprintf("%s:%v", "localhost", listenerPort) + + go func() { + mux := http.NewServeMux() + + // Handle client-side redirect to extract 'auth' code, and close window + mux.HandleFunc("/auth", func(w http.ResponseWriter, r *http.Request) { + authCode = r.URL.Query().Get("code") + w.Write([]byte(` + + +

It is safe to close this window..

+ + + `)) + done <- os.Interrupt + }) + + log.Debugf("listening for auth response on port %v", listenerPort) + if err := http.Serve(listener, mux); err != nil && err != http.ErrServerClosed { + log.Fatalf("authentication listener failed to start: %s\n", err) + } + }() + + signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) + startTimeout(done) + + app, err := mastodon.RegisterApp(context.Background(), &mastodon.AppConfig{ + Server: serverURL(serverName), + ClientName: "Links From Bookmarks", + Scopes: "read:bookmarks read:favourites", + Website: "https://github.com/ivan3bx/proma", + RedirectURIs: fmt.Sprintf("http://%s/auth", listenerHost), + }) + + if err != nil { + return nil, err + } + + log.Debugf("client-id : %s", app.ClientID) + log.Debugf("client-secret: %s", app.ClientSecret) + + if err := browser.OpenURL(app.AuthURI); err != nil { + return nil, err + } + + <-done + + log.Debug("listener stopped") + + if authCode == "" { + return nil, errors.New("auth code was not present, or was blank") + } + + // Create mastodon client + client := mastodon.NewClient(&mastodon.Config{ + Server: serverURL(serverName), + ClientID: app.ClientID, + ClientSecret: app.ClientSecret, + }) + + if err = client.AuthenticateToken(context.Background(), authCode, app.RedirectURI); err != nil { + return nil, err + } + + log.Infof("authenticated to %s\n", client.Config.Server) + + return client, nil +} + +func startTimeout(done chan<- os.Signal) { + timer := time.NewTimer(time.Second * 60) + + go func() { + <-timer.C + log.Debug("timeout exceeded. canceling authentication") + done <- os.Interrupt + }() +} + +func serverURL(serverName string) string { + return fmt.Sprintf("https://%s", serverName) +} + +func newListener() *net.Listener { + listener, err := net.Listen("tcp", "localhost:3334") + + if err != nil { + // attempt to use next available port + listener, err = net.Listen("tcp", "localhost:0") + if err != nil { + panic(err) + } + } + return &listener +} diff --git a/client/authorization_test.go b/client/authorization_test.go new file mode 100644 index 0000000..7845ae2 --- /dev/null +++ b/client/authorization_test.go @@ -0,0 +1,25 @@ +package client + +import "testing" + +func TestServerURL(t *testing.T) { + testCases := []struct { + name string + input string + expected string + }{ + { + name: "success", + input: "mastodon.social", + expected: "https://mastodon.social", + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + actual := serverURL(tc.input) + if actual != tc.expected { + t.Errorf("expected URL to match (%v / %v)\n", tc.expected, actual) + } + }) + } +} diff --git a/cmd/authenticate.go b/cmd/authenticate.go index 9a07cba..bea0b44 100644 --- a/cmd/authenticate.go +++ b/cmd/authenticate.go @@ -5,19 +5,11 @@ package cmd import ( "bufio" - "context" - "errors" - "fmt" - "net" - "net/http" "os" - "os/signal" - "syscall" - "time" "github.com/fatih/color" - "github.com/mattn/go-mastodon" - "github.com/pkg/browser" + "github.com/ivan3bx/proma/client" + log "github.com/sirupsen/logrus" "github.com/spf13/cobra" ) @@ -38,17 +30,17 @@ Examples: and saves an AccessToken to the config file. `, Run: func(cmd *cobra.Command, args []string) { - fmt.Printf("Server: %s\n", yellow(serverName)) - fmt.Printf("Re-run this command with '-server' to use a different server.\n\n") - fmt.Printf("This will launch a browser window in order to authorize this app.\n") - fmt.Println("Hit to continue...") + log.Infof("Server: %s\n", yellow(serverName)) + log.Infof("Re-run this command with '-server' to use a different server.\n\n") + log.Infof("This will launch a browser window in order to authorize this app.\n") + log.Infof("Hit to continue...") bufio.NewReader(os.Stdin).ReadBytes('\n') var err error - client, err = RegisterNewClient() + c, err := client.RegisterNewClient(serverName) cobra.CheckErr(err) - v.Set(serverName, client.Config) + v.Set(serverName, c.Config) cobra.CheckErr(v.WriteConfig()) }, @@ -58,116 +50,9 @@ func init() { rootCmd.AddCommand(authenticateCmd) } -func RegisterNewClient() (*mastodon.Client, error) { - done := make(chan os.Signal, 1) - - // Start temporary server to capture auth code - var authCode string - - // Listen on default port - listener := *newListener() - listenerPort := listener.Addr().(*net.TCPAddr).Port - listenerHost := fmt.Sprintf("%s:%v", "localhost", listenerPort) - - go func() { - mux := http.NewServeMux() - - // Handle client-side redirect to extract 'auth' code, and close window - mux.HandleFunc("/auth", func(w http.ResponseWriter, r *http.Request) { - authCode = r.URL.Query().Get("code") - w.Write([]byte(` - - -

It is safe to close this window..

- - - `)) - done <- os.Interrupt - }) - - debugf("listening for auth response on port %v", listenerPort) - if err := http.Serve(listener, mux); err != nil && err != http.ErrServerClosed { - fmt.Printf("authentication listener failed to start: %s\n", err) - os.Exit(1) - } - }() - - signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) - startTimeout(done) - - app, err := mastodon.RegisterApp(context.Background(), &mastodon.AppConfig{ - Server: serverURL(), - ClientName: "Links From Bookmarks", - Scopes: "read:bookmarks read:favourites", - Website: "https://github.com/ivan3bx/proma", - RedirectURIs: fmt.Sprintf("http://%s/auth", listenerHost), - }) - - if err != nil { - return nil, err - } - - debugf("client-id : %s", app.ClientID) - debugf("client-secret: %s", app.ClientSecret) - - if err := browser.OpenURL(app.AuthURI); err != nil { - return nil, err - } - - <-done - - debug("listener stopped") - - if authCode == "" { - return nil, errors.New("auth code was not present, or was blank") - } - - // Create mastodon client - client := mastodon.NewClient(&mastodon.Config{ - Server: serverURL(), - ClientID: app.ClientID, - ClientSecret: app.ClientSecret, - }) - - if err = client.AuthenticateToken(context.Background(), authCode, app.RedirectURI); err != nil { - return nil, err - } - - fmt.Printf("authenticated to %s\n", client.Config.Server) - - return client, nil -} - -func startTimeout(done chan<- os.Signal) { - timer := time.NewTimer(time.Second * 60) - - go func() { - <-timer.C - debug("timeout exceeded. canceling authentication") - done <- os.Interrupt - }() -} - -func serverURL() string { - return fmt.Sprintf("https://%s", serverName) -} - -func newListener() *net.Listener { - listener, err := net.Listen("tcp", "localhost:3334") - - if err != nil { - // attempt to use next available port - listener, err = net.Listen("tcp", "localhost:0") - if err != nil { - panic(err) - } - } - return &listener -} - func requireClient(cmd *cobra.Command, args []string) { - if client == nil { - fmt.Printf("See 'auth -h' to authenticate\n") + if mClient == nil { + log.Infof("See 'auth -h' to authenticate\n") os.Exit(1) } } diff --git a/cmd/links.go b/cmd/links.go index f3c1e57..04e309d 100644 --- a/cmd/links.go +++ b/cmd/links.go @@ -10,6 +10,8 @@ import ( "os" "strings" + log "github.com/sirupsen/logrus" + "github.com/PuerkitoBio/goquery" "github.com/mattn/go-mastodon" "github.com/spf13/cobra" @@ -29,7 +31,7 @@ var linksCmd = &cobra.Command{ Collects links embedded in the content of your saved bookmarks.`, PreRun: requireClient, Run: func(cmd *cobra.Command, args []string) { - st, err := client.GetBookmarks(cmd.Context(), &mastodon.Pagination{Limit: limit}) + st, err := mClient.GetBookmarks(cmd.Context(), &mastodon.Pagination{Limit: limit}) cobra.CheckErr(err) outputLinks(st) @@ -60,7 +62,7 @@ func outputLinks(status []*mastodon.Status) { if href, ok := s.Attr("href"); ok { if strings.HasPrefix(href, origin) { - debug("skipping internal href: ", href) + log.Debug("skipping internal href: ", href) return } diff --git a/cmd/root.go b/cmd/root.go index a5c5e5b..86b3e77 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -25,12 +25,20 @@ import ( "fmt" "os" + log "github.com/sirupsen/logrus" + "github.com/mattn/go-mastodon" "github.com/spf13/cobra" "github.com/spf13/viper" "golang.org/x/exp/maps" ) +type LogFormatter struct{} + +func (f *LogFormatter) Format(entry *log.Entry) ([]byte, error) { + return []byte(fmt.Sprintf("%s\n", entry.Message)), nil +} + // rootCmd represents the base command when called without any subcommands var rootCmd = &cobra.Command{ Use: "proma", @@ -56,7 +64,7 @@ var ( serverName string cfgFile string verbose bool - client *mastodon.Client + mClient *mastodon.Client ) func init() { @@ -64,7 +72,20 @@ func init() { rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose mode") rootCmd.PersistentFlags().StringVarP(&serverName, "server", "s", "mastodon.social", "server name") - cobra.OnInitialize(initConfig) + cobra.OnInitialize(initLogging, initConfig) +} + +func initLogging() { + if verbose { + log.SetFormatter(&log.TextFormatter{ + DisableTimestamp: true, + PadLevelText: true, + }) + log.Info("Debug logs enabled") + log.SetLevel(log.DebugLevel) + } else { + log.SetFormatter(&LogFormatter{}) + } } func initConfig() { @@ -85,15 +106,15 @@ func initConfig() { err := v.ReadInConfig() if err != nil { - debugf("creating config file: %v", v.ConfigFileUsed()) + log.Debugf("creating config file: %v", v.ConfigFileUsed()) cobra.CheckErr(v.SafeWriteConfig()) } - debugf("reading config file: %v", v.ConfigFileUsed()) + log.Debugf("reading config file: %v", v.ConfigFileUsed()) if len(v.AllSettings()) > 0 && !rootCmd.Flags().Changed("server") { serverName = maps.Keys(v.AllSettings())[0] - debug("using default serverName: ", serverName) + log.Info("using default serverName: ", serverName) } if v.InConfig(serverName) { @@ -106,20 +127,8 @@ func initConfig() { AccessToken: configValues["accesstoken"], } - client = mastodon.NewClient(clientConfig) + mClient = mastodon.NewClient(clientConfig) } else { - fmt.Printf("Credentials missing for server '%s'\n\n", serverName) - } -} - -func debug(a ...any) { - if verbose { - fmt.Println(a...) - } -} - -func debugf(format string, a ...any) { - if verbose { - debug(fmt.Sprintf(format, a...)) + log.Errorf("Credentials missing for server '%s'\n", serverName) } } diff --git a/go.mod b/go.mod index db5d81a..29a44d1 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/fatih/color v1.13.0 github.com/mattn/go-mastodon v0.0.5 github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 + github.com/sirupsen/logrus v1.9.0 github.com/spf13/cobra v1.6.1 github.com/spf13/viper v1.14.0 golang.org/x/exp v0.0.0-20221106115401-f9659909a136 @@ -31,7 +32,7 @@ require ( github.com/subosito/gotenv v1.4.1 // indirect github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 // indirect golang.org/x/net v0.1.0 // indirect - golang.org/x/sys v0.1.0 // indirect + golang.org/x/sys v0.2.0 // indirect golang.org/x/text v0.4.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/go.sum b/go.sum index f434e11..f731aee 100644 --- a/go.sum +++ b/go.sum @@ -168,6 +168,8 @@ github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1: github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0= +github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spf13/afero v1.9.2 h1:j49Hj62F0n+DaZ1dDCvhABaPNSGNkt32oRFxI33IEMw= github.com/spf13/afero v1.9.2/go.mod h1:iUV7ddyEEZPO5gA3zD4fJt6iStLlL+Lg4m2cihcDf8Y= github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w= @@ -337,9 +339,10 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U= -golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.2.0 h1:ljd4t30dBnAvMZaQCevtY0xLLD0A+bRZXbgLMLU1F/A= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=