From 148daaa9633cce5a4005976c62c37fcee00b2474 Mon Sep 17 00:00:00 2001 From: Rustam Gilyazov <16064414+rusq@users.noreply.github.com> Date: Sat, 16 Nov 2024 08:25:59 +1000 Subject: [PATCH 1/8] create CreateAndSelect --- auth/auth_ui/auth_ui.go | 32 ----- auth/auth_ui/auth_ui_test.go | 34 ----- auth/auth_ui/cli.go | 3 +- auth/auth_ui/huh.go | 5 +- auth/auth_ui/validation.go | 4 +- auth/browser.go | 3 +- auth/rod.go | 3 +- .../internal/workspace/workspaceui/api.go | 39 ------ .../workspace/workspaceui/api_test.go | 117 ------------------ .../workspace/workspaceui/ezlogin3000.go | 4 +- .../workspace/workspaceui/filesecrets.go | 4 +- .../workspace/workspaceui/tokencookie.go | 8 +- .../workspace/workspaceui/workspaceui.go | 6 + internal/cache/manager.go | 23 ++++ internal/cache/manager_test.go | 111 +++++++++++++++++ internal/structures/structures.go | 28 +++++ internal/structures/structures_test.go | 31 +++++ 17 files changed, 219 insertions(+), 236 deletions(-) delete mode 100644 auth/auth_ui/auth_ui_test.go delete mode 100644 cmd/slackdump/internal/workspace/workspaceui/api.go delete mode 100644 cmd/slackdump/internal/workspace/workspaceui/api_test.go diff --git a/auth/auth_ui/auth_ui.go b/auth/auth_ui/auth_ui.go index 28f377cf..20dee0ee 100644 --- a/auth/auth_ui/auth_ui.go +++ b/auth/auth_ui/auth_ui.go @@ -1,12 +1,5 @@ package auth_ui -import ( - "errors" - "fmt" - "net/url" - "strings" -) - // LoginType is the login type, that is used to choose the authentication flow, // for example login headlessly or interactively. type LoginType int8 @@ -21,28 +14,3 @@ const ( // LCancel should be returned if the user cancels the login intent. LCancel ) - -var ErrInvalidDomain = errors.New("invalid domain") - -// Sanitize takes a workspace name or URL and returns the workspace name. -func Sanitize(workspace string) (string, error) { - if !strings.Contains(workspace, ".slack.com") && !strings.Contains(workspace, ".") { - return workspace, nil - } - if strings.HasPrefix(workspace, "https://") { - uri, err := url.Parse(workspace) - if err != nil { - return "", err - } - workspace = uri.Host - } - // parse - name, domain, found := strings.Cut(workspace, ".") - if !found { - return "", errors.New("workspace name is empty") - } - if strings.TrimRight(domain, "/") != "slack.com" { - return "", fmt.Errorf("%s: %w", domain, ErrInvalidDomain) - } - return name, nil -} diff --git a/auth/auth_ui/auth_ui_test.go b/auth/auth_ui/auth_ui_test.go deleted file mode 100644 index a1473def..00000000 --- a/auth/auth_ui/auth_ui_test.go +++ /dev/null @@ -1,34 +0,0 @@ -package auth_ui - -import "testing" - -func TestSanitize(t *testing.T) { - type args struct { - workspace string - } - tests := []struct { - name string - args args - want string - wantErr bool - }{ - {"not a URL", args{"blahblah"}, "blahblah", false}, - {"url slash", args{"https://blahblah.slack.com/"}, "blahblah", false}, - {"url no slash", args{"https://blahblah.slack.com"}, "blahblah", false}, - {"url no schema slash", args{"blahblah.slack.com/"}, "blahblah", false}, - {"url no schema no slash", args{"blahblah.slack.com"}, "blahblah", false}, - {"not a slack domain", args{"blahblah.example.com"}, "", true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := Sanitize(tt.args.workspace) - if (err != nil) != tt.wantErr { - t.Errorf("sanitize() error = %v, wantErr %v", err, tt.wantErr) - return - } - if got != tt.want { - t.Errorf("sanitize() got = %v, want %v", got, tt.want) - } - }) - } -} diff --git a/auth/auth_ui/cli.go b/auth/auth_ui/cli.go index 93260af2..38ff5b6e 100644 --- a/auth/auth_ui/cli.go +++ b/auth/auth_ui/cli.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/fatih/color" + "github.com/rusq/slackdump/v3/internal/structures" "golang.org/x/term" ) @@ -30,7 +31,7 @@ func (cl *CLI) RequestWorkspace(w io.Writer) (string, error) { if err != nil { return "", err } - return Sanitize(workspace) + return structures.ExtractWorkspace(workspace) } func (*CLI) RequestCreds(w io.Writer, workspace string) (email string, passwd string, err error) { diff --git a/auth/auth_ui/huh.go b/auth/auth_ui/huh.go index 80d48e51..752be6a7 100644 --- a/auth/auth_ui/huh.go +++ b/auth/auth_ui/huh.go @@ -11,6 +11,7 @@ import ( "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/huh" "github.com/rusq/slackauth" + "github.com/rusq/slackdump/v3/internal/structures" ) // Huh is the Auth UI that uses the huh library to provide a terminal UI. @@ -120,7 +121,7 @@ func (*Huh) RequestLoginType(ctx context.Context, w io.Writer, workspace string) fields = append(fields, huh.NewSelect[LoginType](). TitleFunc(func() string { - wsp, err := Sanitize(ret.Workspace) + wsp, err := structures.ExtractWorkspace(ret.Workspace) if err != nil { return "Select login type" } @@ -150,7 +151,7 @@ func (*Huh) RequestLoginType(ctx context.Context, w io.Writer, workspace string) return ret, err } var err error - ret.Workspace, err = Sanitize(ret.Workspace) + ret.Workspace, err = structures.ExtractWorkspace(ret.Workspace) if err != nil { return ret, err } diff --git a/auth/auth_ui/validation.go b/auth/auth_ui/validation.go index 1b2b9e44..a33f9414 100644 --- a/auth/auth_ui/validation.go +++ b/auth/auth_ui/validation.go @@ -3,6 +3,8 @@ package auth_ui import ( "errors" "regexp" + + "github.com/rusq/slackdump/v3/internal/structures" ) var ( @@ -105,6 +107,6 @@ func valWorkspace(s string) error { if err := valRequired(s); err != nil { return err } - _, err := Sanitize(s) + _, err := structures.ExtractWorkspace(s) return err } diff --git a/auth/browser.go b/auth/browser.go index 4f772be7..e5391d01 100644 --- a/auth/browser.go +++ b/auth/browser.go @@ -8,6 +8,7 @@ import ( "github.com/rusq/slackdump/v3/auth/auth_ui" "github.com/rusq/slackdump/v3/auth/browser" + "github.com/rusq/slackdump/v3/internal/structures" ) var _ Provider = BrowserAuth{} @@ -58,7 +59,7 @@ func NewBrowserAuth(ctx context.Context, opts ...Option) (BrowserAuth, error) { } defer br.opts.flow.Stop() } - if wsp, err := auth_ui.Sanitize(br.opts.workspace); err != nil { + if wsp, err := structures.ExtractWorkspace(br.opts.workspace); err != nil { return br, err } else { br.opts.workspace = wsp diff --git a/auth/rod.go b/auth/rod.go index f415ab46..a49d240c 100644 --- a/auth/rod.go +++ b/auth/rod.go @@ -11,6 +11,7 @@ import ( "github.com/rusq/slackauth" "github.com/rusq/slackdump/v3/auth/auth_ui" + "github.com/rusq/slackdump/v3/internal/structures" "github.com/rusq/slackdump/v3/logger" ) @@ -89,7 +90,7 @@ func NewRODAuth(ctx context.Context, opts ...Option) (RodAuth, error) { for _, opt := range opts { opt(&r.opts) } - if wsp, err := auth_ui.Sanitize(r.opts.workspace); err != nil { + if wsp, err := structures.ExtractWorkspace(r.opts.workspace); err != nil { return r, err } else { r.opts.workspace = wsp diff --git a/cmd/slackdump/internal/workspace/workspaceui/api.go b/cmd/slackdump/internal/workspace/workspaceui/api.go deleted file mode 100644 index 89bcab23..00000000 --- a/cmd/slackdump/internal/workspace/workspaceui/api.go +++ /dev/null @@ -1,39 +0,0 @@ -package workspaceui - -import ( - "context" - "errors" - - "github.com/rusq/slackdump/v3/auth" - "github.com/rusq/slackdump/v3/auth/auth_ui" -) - -//go:generate mockgen -package workspaceui -destination=test_mock_manager.go -source api.go manager -type manager interface { - SaveProvider(workspace string, p auth.Provider) error - Select(workspace string) error -} - -// createAndSelect creates a new workspace with the given provider and selects it. -// It returns the workspace name on success. -func createAndSelect(ctx context.Context, m manager, prov auth.Provider) (string, error) { - authInfo, err := prov.Test(ctx) - if err != nil { - return "", err - } - - wsp, err := auth_ui.Sanitize(authInfo.URL) - if err != nil { - return "", err - } - if wsp == "" { - return "", errors.New("workspace name is empty") - } - if err := m.SaveProvider(wsp, prov); err != nil { - return "", err - } - if err := m.Select(wsp); err != nil { - return "", err - } - return wsp, nil -} diff --git a/cmd/slackdump/internal/workspace/workspaceui/api_test.go b/cmd/slackdump/internal/workspace/workspaceui/api_test.go deleted file mode 100644 index 7b58a2de..00000000 --- a/cmd/slackdump/internal/workspace/workspaceui/api_test.go +++ /dev/null @@ -1,117 +0,0 @@ -package workspaceui - -import ( - "context" - "testing" - - "github.com/rusq/slack" - "github.com/rusq/slackdump/v3/internal/fixtures" - "github.com/rusq/slackdump/v3/internal/mocks/mock_auth" - "github.com/stretchr/testify/assert" - gomock "go.uber.org/mock/gomock" -) - -func Test_createAndSelect(t *testing.T) { - type args struct { - ctx context.Context - // m manager - // prov auth.Provider - } - tests := []struct { - name string - args args - expectFn func(mp *mock_auth.MockProvider, mm *Mockmanager) - want string - wantErr bool - }{ - { - name: "provider test fails", - args: args{ - ctx: context.Background(), - }, - expectFn: func(mp *mock_auth.MockProvider, mm *Mockmanager) { - mp.EXPECT().Test(gomock.Any()).Return(nil, assert.AnError) - }, - want: "", - wantErr: true, - }, - { - name: "save provider fails", - args: args{ - ctx: context.Background(), - }, - expectFn: func(mp *mock_auth.MockProvider, mm *Mockmanager) { - mp.EXPECT().Test(gomock.Any()).Return(fixtures.LoadPtr[slack.AuthTestResponse](string(fixtures.TestAuthTestInfo)), nil) - mm.EXPECT().SaveProvider(gomock.Any(), gomock.Any()).Return(assert.AnError) - }, - want: "", - wantErr: true, - }, - { - name: "select fails", - args: args{ - ctx: context.Background(), - }, - expectFn: func(mp *mock_auth.MockProvider, mm *Mockmanager) { - mp.EXPECT().Test(gomock.Any()).Return(fixtures.LoadPtr[slack.AuthTestResponse](string(fixtures.TestAuthTestInfo)), nil) - mm.EXPECT().SaveProvider(gomock.Any(), gomock.Any()).Return(nil) - mm.EXPECT().Select(gomock.Any()).Return(assert.AnError) - }, - want: "", - wantErr: true, - }, - { - name: "success", - args: args{ - ctx: context.Background(), - }, - expectFn: func(mp *mock_auth.MockProvider, mm *Mockmanager) { - mp.EXPECT().Test(gomock.Any()).Return(fixtures.LoadPtr[slack.AuthTestResponse](string(fixtures.TestAuthTestInfo)), nil) - mm.EXPECT().SaveProvider(gomock.Any(), gomock.Any()).Return(nil) - mm.EXPECT().Select(gomock.Any()).Return(nil) - }, - want: "test", - wantErr: false, - }, - { - name: "url empty fails", - args: args{ - ctx: context.Background(), - }, - expectFn: func(mp *mock_auth.MockProvider, mm *Mockmanager) { - mp.EXPECT().Test(gomock.Any()).Return(&slack.AuthTestResponse{URL: ""}, nil) - }, - want: "", - wantErr: true, - }, - { - name: "url sanitize fails", - args: args{ - ctx: context.Background(), - }, - expectFn: func(mp *mock_auth.MockProvider, mm *Mockmanager) { - mp.EXPECT().Test(gomock.Any()).Return(&slack.AuthTestResponse{URL: "ftp://lol.example.com"}, nil) - }, - want: "", - wantErr: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ctrl := gomock.NewController(t) - mp := mock_auth.NewMockProvider(ctrl) - mm := NewMockmanager(ctrl) - if tt.expectFn != nil { - tt.expectFn(mp, mm) - } - got, err := createAndSelect(tt.args.ctx, mm, mp) - if (err != nil) != tt.wantErr { - t.Errorf("createAndSelect() error = %v, wantErr %v", err, tt.wantErr) - return - } - if got != tt.want { - t.Errorf("createAndSelect() = %v, want %v", got, tt.want) - } - }) - } -} diff --git a/cmd/slackdump/internal/workspace/workspaceui/ezlogin3000.go b/cmd/slackdump/internal/workspace/workspaceui/ezlogin3000.go index e5ec6687..bc1289ed 100644 --- a/cmd/slackdump/internal/workspace/workspaceui/ezlogin3000.go +++ b/cmd/slackdump/internal/workspace/workspaceui/ezlogin3000.go @@ -65,7 +65,7 @@ func playwrightLogin(ctx context.Context, mgr manager) error { return err } - name, err := createAndSelect(ctx, mgr, prov) + name, err := mgr.CreateAndSelect(ctx, prov) if err != nil { return err } @@ -77,7 +77,7 @@ func rodLogin(ctx context.Context, mgr manager) error { if err != nil { return err } - name, err := createAndSelect(ctx, mgr, prov) + name, err := mgr.CreateAndSelect(ctx, prov) if err != nil { return err } diff --git a/cmd/slackdump/internal/workspace/workspaceui/filesecrets.go b/cmd/slackdump/internal/workspace/workspaceui/filesecrets.go index 57420dc1..d0a195e9 100644 --- a/cmd/slackdump/internal/workspace/workspaceui/filesecrets.go +++ b/cmd/slackdump/internal/workspace/workspaceui/filesecrets.go @@ -41,12 +41,12 @@ func fileWithSecrets(ctx context.Context, mgr manager) error { if err != nil { return err } - wsp, err := createAndSelect(ctx, mgr, prov) + name, err := mgr.CreateAndSelect(ctx, prov) if err != nil { return err } - return success(ctx, wsp) + return success(ctx, name) } func validateSecrets(filename string) error { diff --git a/cmd/slackdump/internal/workspace/workspaceui/tokencookie.go b/cmd/slackdump/internal/workspace/workspaceui/tokencookie.go index f3df4579..6ad90eaa 100644 --- a/cmd/slackdump/internal/workspace/workspaceui/tokencookie.go +++ b/cmd/slackdump/internal/workspace/workspaceui/tokencookie.go @@ -11,7 +11,7 @@ import ( const sampleToken = "xoxc-610187951300-604451271234-3473161557912-4c426dd426a45208707725b710302b32dda0ab002b80ccd8c4c8ac9971a11558" -func prgTokenCookie(ctx context.Context, m manager) error { +func prgTokenCookie(ctx context.Context, mgr manager) error { var ( token string cookie string @@ -46,7 +46,7 @@ func prgTokenCookie(ctx context.Context, m manager) error { if err != nil { return err } - name, err := createAndSelect(ctx, m, prov) + name, err := mgr.CreateAndSelect(ctx, prov) if err != nil { confirmed = false retry := askRetry(ctx, name, err) @@ -83,7 +83,7 @@ func makeValidator[P auth.Provider](ctx context.Context, token *string, val *str } } -func prgTokenCookieFile(ctx context.Context, m manager) error { +func prgTokenCookieFile(ctx context.Context, mgr manager) error { var ( token string cookiefile string @@ -116,7 +116,7 @@ func prgTokenCookieFile(ctx context.Context, m manager) error { if err != nil { return err } - name, err := createAndSelect(ctx, m, prov) + name, err := mgr.CreateAndSelect(ctx, prov) if err != nil { confirmed = false retry := askRetry(ctx, name, err) diff --git a/cmd/slackdump/internal/workspace/workspaceui/workspaceui.go b/cmd/slackdump/internal/workspace/workspaceui/workspaceui.go index 6c85766f..9d97a5e6 100644 --- a/cmd/slackdump/internal/workspace/workspaceui/workspaceui.go +++ b/cmd/slackdump/internal/workspace/workspaceui/workspaceui.go @@ -7,12 +7,18 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/huh" + "github.com/rusq/slackdump/v3/auth" "github.com/rusq/slackdump/v3/cmd/slackdump/internal/cfg" "github.com/rusq/slackdump/v3/cmd/slackdump/internal/golang/base" "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui/bubbles/menu" "github.com/rusq/slackdump/v3/internal/cache" ) +//go:generate mockgen -package workspaceui -destination=test_mock_manager.go -source api.go manager +type manager interface { + CreateAndSelect(ctx context.Context, p auth.Provider) (string, error) +} + func WorkspaceNew(ctx context.Context, _ *base.Command, _ []string) error { const ( actLogin = "ezlogin" diff --git a/internal/cache/manager.go b/internal/cache/manager.go index e46f4d64..2d52b520 100644 --- a/internal/cache/manager.go +++ b/internal/cache/manager.go @@ -18,6 +18,7 @@ import ( "github.com/rusq/slackdump/v3/auth" "github.com/rusq/slackdump/v3/internal/osext" + "github.com/rusq/slackdump/v3/internal/structures" ) // Manager is the workspace manager. It is an abstraction over the directory @@ -407,3 +408,25 @@ func (m *Manager) LoadChannels(teamID string, maxAge time.Duration) ([]slack.Cha func (m *Manager) CacheChannels(teamID string, cc []slack.Channel) error { return saveChannels(m.dir, m.channelFile, teamID, cc) } + +func (m *Manager) CreateAndSelect(ctx context.Context, prov auth.Provider) (string, error) { + authInfo, err := prov.Test(ctx) + if err != nil { + return "", err + } + + wsp, err := structures.ExtractWorkspace(authInfo.URL) + if err != nil { + return "", err + } + if wsp == "" { + return "", errors.New("workspace name is empty") + } + if err := m.SaveProvider(wsp, prov); err != nil { + return "", err + } + if err := m.Select(wsp); err != nil { + return "", err + } + return wsp, nil +} diff --git a/internal/cache/manager_test.go b/internal/cache/manager_test.go index 43e4c9a9..627340e5 100644 --- a/internal/cache/manager_test.go +++ b/internal/cache/manager_test.go @@ -1,14 +1,20 @@ package cache import ( + "context" "errors" "io" + "net/http" "sort" "strings" "testing" + "github.com/rusq/slack" + "github.com/rusq/slackdump/v3/auth" "github.com/rusq/slackdump/v3/internal/fixtures" + "github.com/rusq/slackdump/v3/internal/mocks/mock_auth" "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" ) func Test_currentWsp(t *testing.T) { @@ -140,3 +146,108 @@ func TestManager_ExistsErr(t *testing.T) { assert.Equal(t, e.Workspace, "baz") }) } + +func TestManager_CreateAndSelect(t *testing.T) { + type fields struct { + // dir string + authOptions []auth.Option + userFile string + channelFile string + } + type args struct { + ctx context.Context + // prov auth.Provider + } + tests := []struct { + name string + fields fields + expectFn func(mp *mock_auth.MockProvider) + args args + want string + wantErr bool + }{ + { + name: "provider test fails", + args: args{ + ctx: context.Background(), + }, + expectFn: func(mp *mock_auth.MockProvider) { + mp.EXPECT().Test(gomock.Any()).Return(nil, assert.AnError) + }, + want: "", + wantErr: true, + }, + { + name: "url empty fails", + args: args{ + ctx: context.Background(), + }, + expectFn: func(mp *mock_auth.MockProvider) { + mp.EXPECT().Test(gomock.Any()).Return(&slack.AuthTestResponse{URL: ""}, nil) + }, + want: "", + wantErr: true, + }, + { + name: "url sanitize fails", + args: args{ + ctx: context.Background(), + }, + expectFn: func(mp *mock_auth.MockProvider) { + mp.EXPECT().Test(gomock.Any()).Return(&slack.AuthTestResponse{URL: "ftp://lol.example.com"}, nil) + }, + want: "", + wantErr: true, + }, + { + name: "success", + args: args{ + ctx: context.Background(), + }, + expectFn: func(mp *mock_auth.MockProvider) { + mp.EXPECT().Test(gomock.Any()).Return(fixtures.LoadPtr[slack.AuthTestResponse](string(fixtures.TestAuthTestInfo)), nil) + mp.EXPECT().Validate().Return(nil) + mp.EXPECT().SlackToken().Return(fixtures.TestClientToken) + mp.EXPECT().Cookies().Return([]*http.Cookie{}) + }, + want: "test", + wantErr: false, + }, + { + name: "save provider fails", + args: args{ + ctx: context.Background(), + }, + expectFn: func(mp *mock_auth.MockProvider) { + mp.EXPECT().Test(gomock.Any()).Return(fixtures.LoadPtr[slack.AuthTestResponse](string(fixtures.TestAuthTestInfo)), nil) + mp.EXPECT().Validate().Return(assert.AnError) // emulate the provider validation error + }, + want: "", + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + m := &Manager{ + dir: dir, + authOptions: tt.fields.authOptions, + userFile: tt.fields.userFile, + channelFile: tt.fields.channelFile, + } + ctrl := gomock.NewController(t) + mp := mock_auth.NewMockProvider(ctrl) + if tt.expectFn != nil { + tt.expectFn(mp) + } + got, err := m.CreateAndSelect(tt.args.ctx, mp) + if (err != nil) != tt.wantErr { + t.Errorf("Manager.CreateAndSelect() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("Manager.CreateAndSelect() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/structures/structures.go b/internal/structures/structures.go index fe99ab28..017eaf6c 100644 --- a/internal/structures/structures.go +++ b/internal/structures/structures.go @@ -3,7 +3,10 @@ package structures import ( "errors" + "fmt" + "net/url" "regexp" + "strings" ) const ( @@ -26,3 +29,28 @@ func ValidateToken(token string) error { } return nil } + +var ErrInvalidDomain = errors.New("invalid domain") + +// ExtractWorkspace takes a workspace name or URL and returns the workspace name. +func ExtractWorkspace(workspace string) (string, error) { + if !strings.Contains(workspace, ".slack.com") && !strings.Contains(workspace, ".") { + return workspace, nil + } + if strings.HasPrefix(workspace, "https://") { + uri, err := url.Parse(workspace) + if err != nil { + return "", err + } + workspace = uri.Host + } + // parse + name, domain, found := strings.Cut(workspace, ".") + if !found { + return "", errors.New("workspace name is empty") + } + if strings.TrimRight(domain, "/") != "slack.com" { + return "", fmt.Errorf("%s: %w", domain, ErrInvalidDomain) + } + return name, nil +} diff --git a/internal/structures/structures_test.go b/internal/structures/structures_test.go index 7166a83e..0b176244 100644 --- a/internal/structures/structures_test.go +++ b/internal/structures/structures_test.go @@ -75,3 +75,34 @@ func TestValidateToken(t *testing.T) { }) } } + +func TestSanitize(t *testing.T) { + type args struct { + workspace string + } + tests := []struct { + name string + args args + want string + wantErr bool + }{ + {"not a URL", args{"blahblah"}, "blahblah", false}, + {"url slash", args{"https://blahblah.slack.com/"}, "blahblah", false}, + {"url no slash", args{"https://blahblah.slack.com"}, "blahblah", false}, + {"url no schema slash", args{"blahblah.slack.com/"}, "blahblah", false}, + {"url no schema no slash", args{"blahblah.slack.com"}, "blahblah", false}, + {"not a slack domain", args{"blahblah.example.com"}, "", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ExtractWorkspace(tt.args.workspace) + if (err != nil) != tt.wantErr { + t.Errorf("sanitize() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("sanitize() got = %v, want %v", got, tt.want) + } + }) + } +} From b0bf651697d6b5970d3df7ddb0c6e866d5cba80c Mon Sep 17 00:00:00 2001 From: Rustam Gilyazov <16064414+rusq@users.noreply.github.com> Date: Sat, 16 Nov 2024 10:14:58 +1000 Subject: [PATCH 2/8] move env logic to auth package --- auth/env.go | 55 +++++++++++++++ .../filesecrets_test.go => auth/env_test.go | 12 ++-- .../workspace/workspaceui/filesecrets.go | 44 +----------- .../workspaceui/test_mock_manager.go | 69 ------------------- internal/cache/manager.go | 6 +- internal/structures/structures_test.go | 6 +- 6 files changed, 69 insertions(+), 123 deletions(-) create mode 100644 auth/env.go rename cmd/slackdump/internal/workspace/workspaceui/filesecrets_test.go => auth/env_test.go (90%) delete mode 100644 cmd/slackdump/internal/workspace/workspaceui/test_mock_manager.go diff --git a/auth/env.go b/auth/env.go new file mode 100644 index 00000000..5ca194aa --- /dev/null +++ b/auth/env.go @@ -0,0 +1,55 @@ +package auth + +import ( + "errors" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/joho/godotenv" + "github.com/rusq/slackdump/v3/internal/structures" +) + +func parseDotEnv(fsys fs.FS, filename string) (string, string, error) { + const ( + tokenKey = "SLACK_TOKEN" + cookieKey = "SLACK_COOKIE" + + clientTokenPrefix = "xoxc-" + ) + f, err := fsys.Open(filename) + if err != nil { + return "", "", err + } + defer f.Close() + secrets, err := godotenv.Parse(f) + if err != nil { + return "", "", errors.New("not a secrets file") + } + token, ok := secrets[tokenKey] + if !ok { + return "", "", errors.New("no SLACK_TOKEN found in the file") + } + if err := structures.ValidateToken(token); err != nil { + return "", "", err + } + if !strings.HasPrefix(token, clientTokenPrefix) { + return token, "", nil + } + cook, ok := secrets[cookieKey] + if !ok { + return "", "", errors.New("no SLACK_COOKIE found in the file") + } + if !strings.HasPrefix(cook, "xoxd-") { + return "", "", errors.New("invalid cookie") + } + return token, cook, nil +} + +func ParseDotEnv(filename string) (string, string, error) { + dir := filepath.Dir(filename) + dirfs := os.DirFS(dir) + pth := filepath.Base(filename) + return parseDotEnv(dirfs, pth) +} diff --git a/cmd/slackdump/internal/workspace/workspaceui/filesecrets_test.go b/auth/env_test.go similarity index 90% rename from cmd/slackdump/internal/workspace/workspaceui/filesecrets_test.go rename to auth/env_test.go index 46343361..123d7f42 100644 --- a/cmd/slackdump/internal/workspace/workspaceui/filesecrets_test.go +++ b/auth/env_test.go @@ -1,4 +1,4 @@ -package workspaceui +package auth import ( "os" @@ -26,7 +26,7 @@ func writeEnvFile(t *testing.T, filename string, m map[string]string) string { return filename } -func Test_parseSecretsTxt(t *testing.T) { +func Test_ParseDotEnv(t *testing.T) { dir := t.TempDir() type args struct { filename string @@ -121,16 +121,16 @@ func Test_parseSecretsTxt(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, got1, err := parseSecretsTxt(tt.args.filename) + got, got1, err := ParseDotEnv(tt.args.filename) if (err != nil) != tt.wantErr { - t.Errorf("parseSecretsTxt() error = %v, wantErr %v", err, tt.wantErr) + t.Errorf("ParseDotEnv() error = %v, wantErr %v", err, tt.wantErr) return } if got != tt.want { - t.Errorf("parseSecretsTxt() got = %v, want %v", got, tt.want) + t.Errorf("ParseDotEnv() got = %v, want %v", got, tt.want) } if got1 != tt.want1 { - t.Errorf("parseSecretsTxt() got1 = %v, want %v", got1, tt.want1) + t.Errorf("ParseDotEnv() got1 = %v, want %v", got1, tt.want1) } }) } diff --git a/cmd/slackdump/internal/workspace/workspaceui/filesecrets.go b/cmd/slackdump/internal/workspace/workspaceui/filesecrets.go index d0a195e9..23c6fbc3 100644 --- a/cmd/slackdump/internal/workspace/workspaceui/filesecrets.go +++ b/cmd/slackdump/internal/workspace/workspaceui/filesecrets.go @@ -3,15 +3,11 @@ package workspaceui import ( "context" "errors" - "os" - "strings" "github.com/charmbracelet/huh" - "github.com/joho/godotenv" "github.com/rusq/slackdump/v3/auth" "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui" - "github.com/rusq/slackdump/v3/internal/structures" ) func fileWithSecrets(ctx context.Context, mgr manager) error { @@ -33,7 +29,7 @@ func fileWithSecrets(ctx context.Context, mgr manager) error { } } - tok, cookie, err := parseSecretsTxt(filename) + tok, cookie, err := auth.ParseDotEnv(filename) if err != nil { return err } @@ -50,42 +46,6 @@ func fileWithSecrets(ctx context.Context, mgr manager) error { } func validateSecrets(filename string) error { - _, _, err := parseSecretsTxt(filename) + _, _, err := auth.ParseDotEnv(filename) return err } - -func parseSecretsTxt(filename string) (string, string, error) { - const ( - tokenKey = "SLACK_TOKEN" - cookieKey = "SLACK_COOKIE" - - clientTokenPrefix = "xoxc-" - ) - f, err := os.Open(filename) - if err != nil { - return "", "", err - } - defer f.Close() - secrets, err := godotenv.Parse(f) - if err != nil { - return "", "", errors.New("not a secrets file") - } - token, ok := secrets[tokenKey] - if !ok { - return "", "", errors.New("no SLACK_TOKEN found in the file") - } - if err := structures.ValidateToken(token); err != nil { - return "", "", err - } - if !strings.HasPrefix(token, clientTokenPrefix) { - return token, "", nil - } - cook, ok := secrets[cookieKey] - if !ok { - return "", "", errors.New("no SLACK_COOKIE found in the file") - } - if !strings.HasPrefix(cook, "xoxd-") { - return "", "", errors.New("invalid cookie") - } - return token, cook, nil -} diff --git a/cmd/slackdump/internal/workspace/workspaceui/test_mock_manager.go b/cmd/slackdump/internal/workspace/workspaceui/test_mock_manager.go deleted file mode 100644 index abb8bde2..00000000 --- a/cmd/slackdump/internal/workspace/workspaceui/test_mock_manager.go +++ /dev/null @@ -1,69 +0,0 @@ -// Code generated by MockGen. DO NOT EDIT. -// Source: api.go -// -// Generated by this command: -// -// mockgen -package workspaceui -destination=test_mock_manager.go -source api.go manager -// - -// Package workspaceui is a generated GoMock package. -package workspaceui - -import ( - reflect "reflect" - - auth "github.com/rusq/slackdump/v3/auth" - gomock "go.uber.org/mock/gomock" -) - -// Mockmanager is a mock of manager interface. -type Mockmanager struct { - ctrl *gomock.Controller - recorder *MockmanagerMockRecorder - isgomock struct{} -} - -// MockmanagerMockRecorder is the mock recorder for Mockmanager. -type MockmanagerMockRecorder struct { - mock *Mockmanager -} - -// NewMockmanager creates a new mock instance. -func NewMockmanager(ctrl *gomock.Controller) *Mockmanager { - mock := &Mockmanager{ctrl: ctrl} - mock.recorder = &MockmanagerMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *Mockmanager) EXPECT() *MockmanagerMockRecorder { - return m.recorder -} - -// SaveProvider mocks base method. -func (m *Mockmanager) SaveProvider(workspace string, p auth.Provider) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SaveProvider", workspace, p) - ret0, _ := ret[0].(error) - return ret0 -} - -// SaveProvider indicates an expected call of SaveProvider. -func (mr *MockmanagerMockRecorder) SaveProvider(workspace, p any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveProvider", reflect.TypeOf((*Mockmanager)(nil).SaveProvider), workspace, p) -} - -// Select mocks base method. -func (m *Mockmanager) Select(workspace string) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Select", workspace) - ret0, _ := ret[0].(error) - return ret0 -} - -// Select indicates an expected call of Select. -func (mr *MockmanagerMockRecorder) Select(workspace any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Select", reflect.TypeOf((*Mockmanager)(nil).Select), workspace) -} diff --git a/internal/cache/manager.go b/internal/cache/manager.go index 2d52b520..70b3aecb 100644 --- a/internal/cache/manager.go +++ b/internal/cache/manager.go @@ -144,8 +144,8 @@ func (m *Manager) LoadProvider(name string) (auth.Provider, error) { return loadCreds(filer, m.filepath(name)) } -// SaveProvider saves the provider to the file, no questions asked. -func (m *Manager) SaveProvider(name string, p auth.Provider) error { +// saveProvider saves the provider to the file, no questions asked. +func (m *Manager) saveProvider(name string, p auth.Provider) error { return saveCreds(filer, m.filepath(name), p) } @@ -422,7 +422,7 @@ func (m *Manager) CreateAndSelect(ctx context.Context, prov auth.Provider) (stri if wsp == "" { return "", errors.New("workspace name is empty") } - if err := m.SaveProvider(wsp, prov); err != nil { + if err := m.saveProvider(wsp, prov); err != nil { return "", err } if err := m.Select(wsp); err != nil { diff --git a/internal/structures/structures_test.go b/internal/structures/structures_test.go index 0b176244..55ae4757 100644 --- a/internal/structures/structures_test.go +++ b/internal/structures/structures_test.go @@ -76,7 +76,7 @@ func TestValidateToken(t *testing.T) { } } -func TestSanitize(t *testing.T) { +func TestExtractWorkspace(t *testing.T) { type args struct { workspace string } @@ -97,11 +97,11 @@ func TestSanitize(t *testing.T) { t.Run(tt.name, func(t *testing.T) { got, err := ExtractWorkspace(tt.args.workspace) if (err != nil) != tt.wantErr { - t.Errorf("sanitize() error = %v, wantErr %v", err, tt.wantErr) + t.Errorf("ExtractWorkspace() error = %v, wantErr %v", err, tt.wantErr) return } if got != tt.want { - t.Errorf("sanitize() got = %v, want %v", got, tt.want) + t.Errorf("ExtractWorkspace() got = %v, want %v", got, tt.want) } }) } From c0477f754ca0626ad7d61af9971bf93f61f45ba2 Mon Sep 17 00:00:00 2001 From: Rustam Gilyazov <16064414+rusq@users.noreply.github.com> Date: Sat, 16 Nov 2024 18:00:13 +1000 Subject: [PATCH 3/8] use new workspace/new by default --- cmd/slackdump/internal/bootstrap/provider.go | 8 ++- .../internal/workspace/assets/import.md | 24 +++++++ .../internal/workspace/assets/list.md | 14 ++++ cmd/slackdump/internal/workspace/import.go | 65 +++++++++++++++++++ cmd/slackdump/internal/workspace/list.go | 25 ++----- cmd/slackdump/internal/workspace/workspace.go | 1 + .../workspace/workspaceui/workspaceui.go | 9 +++ cmd/slackdump/main.go | 32 ++++++--- 8 files changed, 148 insertions(+), 30 deletions(-) create mode 100644 cmd/slackdump/internal/workspace/assets/import.md create mode 100644 cmd/slackdump/internal/workspace/assets/list.md create mode 100644 cmd/slackdump/internal/workspace/import.go diff --git a/cmd/slackdump/internal/bootstrap/provider.go b/cmd/slackdump/internal/bootstrap/provider.go index c42b5e1c..241f0caa 100644 --- a/cmd/slackdump/internal/bootstrap/provider.go +++ b/cmd/slackdump/internal/bootstrap/provider.go @@ -8,19 +8,21 @@ import ( "github.com/rusq/slackdump/v3/auth" "github.com/rusq/slackdump/v3/cmd/slackdump/internal/cfg" "github.com/rusq/slackdump/v3/cmd/slackdump/internal/workspace" + "github.com/rusq/slackdump/v3/cmd/slackdump/internal/workspace/workspaceui" "github.com/rusq/slackdump/v3/internal/cache" ) func CurrentOrNewProviderCtx(ctx context.Context) (context.Context, error) { - prov, err := workspace.AuthCurrent(ctx, cfg.CacheDir(), cfg.Workspace, cfg.LegacyBrowser) + cachedir := cfg.CacheDir() + prov, err := workspace.AuthCurrent(ctx, cachedir, cfg.Workspace, cfg.LegacyBrowser) if err != nil { if errors.Is(err, cache.ErrNoWorkspaces) { // ask to create a new workspace - if err := workspace.CmdWspNew.Run(ctx, workspace.CmdWspNew, []string{}); err != nil { + if err := workspaceui.ShowUI(ctx, true); err != nil { return ctx, fmt.Errorf("auth error: %w", err) } // one more time... - prov, err = workspace.AuthCurrent(ctx, cfg.CacheDir(), cfg.Workspace, cfg.LegacyBrowser) + prov, err = workspace.AuthCurrent(ctx, cachedir, cfg.Workspace, cfg.LegacyBrowser) if err != nil { return ctx, err } diff --git a/cmd/slackdump/internal/workspace/assets/import.md b/cmd/slackdump/internal/workspace/assets/import.md new file mode 100644 index 00000000..a7c2bab7 --- /dev/null +++ b/cmd/slackdump/internal/workspace/assets/import.md @@ -0,0 +1,24 @@ +# Workspace Import Command + +**Import** allows you to import credentials from a .env or secrets.txt file. + +It requires the file to have the following format: +``` +SLACK_TOKEN=xoxc-... +SLACK_COOKIE=xoxd-... +``` + +`SLACK_TOKEN` can be one of the following: + +- xoxa-...: app token +- xoxb-...: bot token +- xoxc-...: client token +- xoxe-...: export token +- xoxp-...: legacy user token + +`SLACK_COOKIE` is only required, if the `SLACK_TOKEN` is a client type token +(starts with `xoxc-`). + +It will test the provided credentials, and if successful, encrypt and save +them to the to the slackdump credential storage. It is recommended to delete +the .env file afterwards. diff --git a/cmd/slackdump/internal/workspace/assets/list.md b/cmd/slackdump/internal/workspace/assets/list.md new file mode 100644 index 00000000..d0dbd3d6 --- /dev/null +++ b/cmd/slackdump/internal/workspace/assets/list.md @@ -0,0 +1,14 @@ +# Workspace List Command + +**List** allows to list Slack Workspaces, that you have previously +authenticated in. It supports several output formats: +- full (default): outputs workspace names, filenames, and last modification. +- bare: outputs just workspace names, with the current workspace marked with + an asterisk. +- all: outputs all information, including the team name and the user name for + each workspace. + +If the "all" listing is requested, Slackdump will interrogate the Slack API to +get the team name and the user name for each workspace. This may take some +time, as it involves multiple network requests, depending on your network +speed and the number of workspaces. diff --git a/cmd/slackdump/internal/workspace/import.go b/cmd/slackdump/internal/workspace/import.go new file mode 100644 index 00000000..dcb26ef7 --- /dev/null +++ b/cmd/slackdump/internal/workspace/import.go @@ -0,0 +1,65 @@ +package workspace + +import ( + "context" + _ "embed" + "errors" + + "github.com/rusq/slackdump/v3/auth" + "github.com/rusq/slackdump/v3/cmd/slackdump/internal/cfg" + "github.com/rusq/slackdump/v3/cmd/slackdump/internal/golang/base" + "github.com/rusq/slackdump/v3/internal/cache" +) + +//go:embed assets/import.md +var importMd string + +var CmdImport = &base.Command{ + UsageLine: baseCommand + " import [flags] filename", + Short: "import credentials from .env or secrets.txt file", + Long: importMd, + FlagMask: flagmask, + PrintFlags: true, + Run: cmdRunImport, + RequireAuth: false, +} + +func cmdRunImport(ctx context.Context, cmd *base.Command, args []string) error { + if len(args) != 1 { + base.SetExitStatus(base.SInvalidParameters) + return errors.New("missing filename") + } + + filename := args[0] + + if err := importFile(ctx, filename); err != nil { + return err + } + return nil +} + +func importFile(ctx context.Context, filename string) error { + token, cookies, err := auth.ParseDotEnv(filename) + if err != nil { + base.SetExitStatus(base.SUserError) + return err + } + m, err := cache.NewManager(cfg.CacheDir()) + if err != nil { + base.SetExitStatus(base.SCacheError) + return err + } + prov, err := auth.NewValueAuth(token, cookies) + if err != nil { + base.SetExitStatus(base.SAuthError) + return err + } + wsp, err := m.CreateAndSelect(ctx, prov) + if err != nil { + base.SetExitStatus(base.SCacheError) + return err + } + cfg.Log.Printf("Workspace %q added and selected. It is advised that you delete the file %q", wsp, filename) + + return nil +} diff --git a/cmd/slackdump/internal/workspace/list.go b/cmd/slackdump/internal/workspace/list.go index 62650a66..1b9729dc 100644 --- a/cmd/slackdump/internal/workspace/list.go +++ b/cmd/slackdump/internal/workspace/list.go @@ -2,6 +2,7 @@ package workspace import ( "context" + _ "embed" "errors" "fmt" "io" @@ -19,25 +20,13 @@ import ( "github.com/rusq/slackdump/v3/internal/cache" ) +//go:embed assets/list.md +var listMd string + var CmdWspList = &base.Command{ - UsageLine: baseCommand + " list [flags]", - Short: "list saved authentication information", - Long: ` -# Workspace List Command - -**List** allows to list Slack Workspaces, that you have previously authenticated -in. It supports several output formats: -- full (default): outputs workspace names, filenames, and last modification. -- bare: outputs just workspace names, with the current workspace marked with an - asterisk. -- all: outputs all information, including the team name and the user name for - each workspace. - -If the "all" listing is requested, Slackdump will interrogate the Slack API to -get the team name and the user name for each workspace. This may take some -time, as it involves multiple network requests, depending on your network -speed and the number of workspaces. -`, + UsageLine: baseCommand + " list [flags]", + Short: "list saved authentication information", + Long: listMd, FlagMask: flagmask, PrintFlags: true, } diff --git a/cmd/slackdump/internal/workspace/workspace.go b/cmd/slackdump/internal/workspace/workspace.go index a73ed7c8..0dbb5106 100644 --- a/cmd/slackdump/internal/workspace/workspace.go +++ b/cmd/slackdump/internal/workspace/workspace.go @@ -48,6 +48,7 @@ automatically detected to be: RequireAuth: false, Commands: []*base.Command{ CmdWspNew, + CmdImport, CmdWspList, CmdWspSelect, CmdWspDel, diff --git a/cmd/slackdump/internal/workspace/workspaceui/workspaceui.go b/cmd/slackdump/internal/workspace/workspaceui/workspaceui.go index 9d97a5e6..f4f14c33 100644 --- a/cmd/slackdump/internal/workspace/workspaceui/workspaceui.go +++ b/cmd/slackdump/internal/workspace/workspaceui/workspaceui.go @@ -20,6 +20,12 @@ type manager interface { } func WorkspaceNew(ctx context.Context, _ *base.Command, _ []string) error { + return ShowUI(ctx, false) +} + +// ShowUI shows the authentication menu. If quicklogin is set to true, +// it will quit after the user has successfully authenticated. +func ShowUI(ctx context.Context, quicklogin bool) error { const ( actLogin = "ezlogin" actToken = "token" @@ -97,6 +103,9 @@ LOOP: } return err } + if quicklogin { + return nil + } } return nil diff --git a/cmd/slackdump/main.go b/cmd/slackdump/main.go index 358c0692..79a5b263 100644 --- a/cmd/slackdump/main.go +++ b/cmd/slackdump/main.go @@ -5,6 +5,7 @@ import ( "flag" "fmt" "log" + "log/slog" "os" "os/signal" "runtime/trace" @@ -239,15 +240,16 @@ func initTrace(filename string) error { return nil } -// initLog initialises the logging and returns the context with the Logger. If -// the filename is not empty, the file will be opened, and the logger output will -// be switch to that file. Returns the initialised logger, stop function and an -// error, if any. The stop function must be called in the deferred call, it will -// close the log file, if it is open. If the error is returned the stop function -// is nil. +// initLog initialises the logging and returns the context with the Logger. If the +// filename is not empty, the file will be opened, and the logger output will +// be switch to that file. Returns the initialised logger, stop function and +// an error, if any. The stop function must be called in the deferred call, it +// will close the log file, if it is open. If the error is returned the stop +// function is nil. func initLog(filename string, verbose bool) (*dlog.Logger, error) { lg := logger.Default if verbose { + slog.SetLogLoggerLevel(slog.LevelDebug) lg.SetDebug(verbose) lg.SetFlags(lg.Flags() | log.Lmicroseconds) } @@ -262,6 +264,10 @@ func initLog(filename string, verbose bool) (*dlog.Logger, error) { return lg, fmt.Errorf("failed to create the log file: %w", err) } lg.SetOutput(lf) + sl := slog.New(slog.NewTextHandler(lf, &slog.HandlerOptions{ + Level: iftrue(verbose, slog.LevelDebug, slog.LevelInfo), + })) + slog.SetDefault(sl) base.AtExit(func() { if err := lf.Close(); err != nil { @@ -272,6 +278,13 @@ func initLog(filename string, verbose bool) (*dlog.Logger, error) { return lg, nil } +func iftrue[T any](cond bool, t T, f T) T { + if cond { + return t + } + return f +} + // secrets defines the names of the supported secret files that we load our // secrets from. Inexperienced Windows users might have bad experience trying // to create .env file with the notepad as it will battle for having the @@ -295,16 +308,17 @@ const ( ) func whatDo() (choice, error) { - fmt.Print("\n" + cfg.Version.String() + "\n") + versionstr := cfg.Version.String() var ans choice err := huh.NewForm(huh.NewGroup(huh.NewSelect[choice](). - Title("What do you want to do?"). + Title(versionstr). + Description("What do you want to do?"). Options( huh.NewOption(string(choiceHelp), choiceHelp), huh.NewOption(string(choiceWizard), choiceWizard), huh.NewOption(string(choiceExit), choiceExit), - ).Value(&ans))).WithTheme(ui.HuhTheme()).Run() + ).Value(&ans))).WithTheme(ui.HuhTheme()).WithKeyMap(ui.DefaultHuhKeymap).Run() return ans, err } From bb44d4579b5dc37b7b2e25eef1156ad8cdbda6d3 Mon Sep 17 00:00:00 2001 From: Rustam Gilyazov <16064414+rusq@users.noreply.github.com> Date: Sat, 16 Nov 2024 22:13:45 +1000 Subject: [PATCH 4/8] workspace selection logic --- auth/auth_ui/huh.go | 2 +- auth/browser.go | 4 + cmd/slackdump/internal/ui/cfgui/model.go | 4 +- cmd/slackdump/internal/ui/ui.go | 2 +- .../internal/workspace/wiz_select.go | 87 ++++--------------- .../workspace/workspaceui/ezlogin3000.go | 42 ++++----- .../internal/workspace/workspaceui/select.go | 72 +++++++++++++++ .../workspace/workspaceui/workspaceui.go | 52 +++++++++-- cmd/slackdump/main.go | 17 +--- 9 files changed, 160 insertions(+), 122 deletions(-) create mode 100644 cmd/slackdump/internal/workspace/workspaceui/select.go diff --git a/auth/auth_ui/huh.go b/auth/auth_ui/huh.go index 752be6a7..607cb98f 100644 --- a/auth/auth_ui/huh.go +++ b/auth/auth_ui/huh.go @@ -27,7 +27,7 @@ func (h *Huh) RequestWorkspace(w io.Writer) (string, error) { Value(&workspace). Validate(valWorkspace). Description("The workspace name is the part of the URL that comes before `.slack.com' in\nhttps://.slack.com/. Both workspace name or URL are acceptable."), - )).WithTheme(Theme).Run() + )).WithTheme(Theme).WithKeyMap(keymap).Run() if err != nil { return "", err } diff --git a/auth/browser.go b/auth/browser.go index e5391d01..56e13244 100644 --- a/auth/browser.go +++ b/auth/browser.go @@ -3,6 +3,7 @@ package auth import ( "context" "io" + "log/slog" "os" "time" @@ -64,6 +65,9 @@ func NewBrowserAuth(ctx context.Context, opts ...Option) (BrowserAuth, error) { } else { br.opts.workspace = wsp } + slog.Info("Please wait while Playwright is initialising.") + slog.Info("If you're running it for the first time, it will take a couple of minutes...") + auther, err := browser.New(br.opts.workspace, browser.OptBrowser(br.opts.browser), browser.OptTimeout(br.opts.loginTimeout), browser.OptVerbose(br.opts.verbose)) if err != nil { return br, err diff --git a/cmd/slackdump/internal/ui/cfgui/model.go b/cmd/slackdump/internal/ui/cfgui/model.go index 06d64f21..7598b343 100644 --- a/cmd/slackdump/internal/ui/cfgui/model.go +++ b/cmd/slackdump/internal/ui/cfgui/model.go @@ -85,7 +85,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.child = child return m, cmd } - +CASE: switch msg := msg.(type) { case updaters.WMClose: // child sends a close message @@ -121,7 +121,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, m.keymap.Select): i, j := locateParam(m.cfgFn(), m.cursor) if i == notFound || j == notFound { - return m, nil + break CASE } if params := m.cfgFn()[i].Params[j]; params.Updater != nil { if params.Inline { diff --git a/cmd/slackdump/internal/ui/ui.go b/cmd/slackdump/internal/ui/ui.go index 9c7b417c..d9eaf395 100644 --- a/cmd/slackdump/internal/ui/ui.go +++ b/cmd/slackdump/internal/ui/ui.go @@ -3,7 +3,7 @@ package ui const ( // MenuSeparator is the separator to use in the wizard menus. - MenuSeparator = "────────────" + MenuSeparator = "────────────────" ) type inputOptions struct { diff --git a/cmd/slackdump/internal/workspace/wiz_select.go b/cmd/slackdump/internal/workspace/wiz_select.go index 53e519f5..cd0f1201 100644 --- a/cmd/slackdump/internal/workspace/wiz_select.go +++ b/cmd/slackdump/internal/workspace/wiz_select.go @@ -2,14 +2,14 @@ package workspace import ( "context" + "errors" "fmt" "github.com/charmbracelet/bubbles/table" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" "github.com/rusq/slackdump/v3/cmd/slackdump/internal/cfg" "github.com/rusq/slackdump/v3/cmd/slackdump/internal/golang/base" - "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui" + "github.com/rusq/slackdump/v3/cmd/slackdump/internal/workspace/workspaceui" "github.com/rusq/slackdump/v3/internal/cache" "github.com/rusq/slackdump/v3/logger" ) @@ -35,7 +35,7 @@ func wizSelect(ctx context.Context, cmd *base.Command, args []string) error { if err != nil { return fmt.Errorf("workspace select wizard error: %w", err) } - if newWsp := mod.(selectModel).selected; newWsp != "" { + if newWsp := mod.(workspaceui.SelectModel).Selected; newWsp != "" { if err := m.Select(newWsp); err != nil { base.SetExitStatus(base.SWorkspaceError) return fmt.Errorf("error setting the current workspace: %s", err) @@ -46,16 +46,22 @@ func wizSelect(ctx context.Context, cmd *base.Command, args []string) error { return nil } -func workspaceSelectModel(ctx context.Context, m *cache.Manager) (tea.Model, error) { +func workspaceSelectModel(ctx context.Context, m manager) (tea.Model, error) { wspList, err := m.List() if err != nil { - base.SetExitStatus(base.SCacheError) - return nil, err - } - - if len(wspList) == 0 { - fmt.Println("No workspaces found") - return nil, nil // TODO + if errors.Is(err, cache.ErrNoWorkspaces) { + if err := workspaceui.ShowUI(ctx, true); err != nil { + return nil, err + } + // retry + wspList, err = m.List() + if err != nil { + return nil, err + } + } else { + base.SetExitStatus(base.SUserError) + return nil, err + } } current, err := m.Current() @@ -77,62 +83,5 @@ func workspaceSelectModel(ctx context.Context, m *cache.Manager) (tea.Model, err rows = append(rows, table.Row{w[0], w[1], w[4], w[5], w[6]}) } - t := table.New( - table.WithColumns(columns), - table.WithRows(rows), - table.WithFocused(true), - table.WithHeight(7), - ) - - s := table.Styles{ - Header: ui.DefaultTheme().Focused.Title.Padding(0, 1), - Selected: ui.DefaultTheme().Focused.SelectedLine.Bold(true), - Cell: ui.DefaultTheme().Focused.Text.Padding(0, 1), - } - t.SetStyles(s) - t.Focus() - return selectModel{ - table: t, - style: style{ - FocusedBorder: ui.DefaultTheme().Focused.Border, - }, - }, nil -} - -type selectModel struct { - table table.Model - selected string - finished bool - style style -} - -type style struct { - FocusedBorder lipgloss.Style -} - -func (m selectModel) Init() tea.Cmd { return nil } - -func (m selectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "q", "ctrl+c", "esc": - m.finished = true - return m, tea.Quit - case "enter": - m.selected = m.table.SelectedRow()[1] - m.finished = true - return m, tea.Quit - } - } - m.table, cmd = m.table.Update(msg) - return m, cmd -} - -func (m selectModel) View() string { - if m.finished { - return "" // don't render the table if we've selected a workspace - } - return m.style.FocusedBorder.Render((m.table.View()) + "\n\n" + ui.HuhTheme().Help.Ellipsis.Render("Select the workspace with arrow keys, press [Enter] to confirm, [Esc] to cancel.")) + return workspaceui.NewSelectModel(columns, rows), nil } diff --git a/cmd/slackdump/internal/workspace/workspaceui/ezlogin3000.go b/cmd/slackdump/internal/workspace/workspaceui/ezlogin3000.go index bc1289ed..a4e15add 100644 --- a/cmd/slackdump/internal/workspace/workspaceui/ezlogin3000.go +++ b/cmd/slackdump/internal/workspace/workspaceui/ezlogin3000.go @@ -12,36 +12,22 @@ import ( "github.com/charmbracelet/huh" ) -func ezLogin3000(ctx context.Context, mgr manager) error { - var ( - legacy bool - ) - form := huh.NewForm(huh.NewGroup( - huh.NewConfirm(). - Title("Do you want to use the legacy login?"). - Description("Choose 'Yes' if you had problems in the past with the current EZ-Login."). - Value(&legacy), - )).WithTheme(ui.HuhTheme()).WithKeyMap(ui.DefaultHuhKeymap) - if err := form.RunWithContext(ctx); err != nil { - if errors.Is(err, huh.ErrUserAborted) { - return nil +func brwsLogin(opts *browserOptions) func(ctx context.Context, mgr manager) error { + return func(ctx context.Context, mgr manager) error { + var err error + if opts.UsePlaywright { + err = playwrightLogin(ctx, mgr) + } else { + err = rodLogin(ctx, mgr) } - return err - } - - var err error - if legacy { - err = playwrightLogin(ctx, mgr) - } else { - err = rodLogin(ctx, mgr) - } - if err != nil { - if errors.Is(err, auth.ErrCancelled) { - return nil + if err != nil { + if errors.Is(err, auth.ErrCancelled) { + return nil + } + return err } - return err + return nil } - return nil } func playwrightLogin(ctx context.Context, mgr manager) error { @@ -52,6 +38,8 @@ func playwrightLogin(ctx context.Context, mgr manager) error { huh.NewOption("Chromium", browser.Bchromium), huh.NewOption("Firefox", browser.Bfirefox), ). + Title("Playwright login"). + Description("Choose the browser to use for authentication"). Value(&brws), )).WithTheme(ui.HuhTheme()).WithKeyMap(ui.DefaultHuhKeymap) if err := formBrowser.RunWithContext(ctx); err != nil { diff --git a/cmd/slackdump/internal/workspace/workspaceui/select.go b/cmd/slackdump/internal/workspace/workspaceui/select.go new file mode 100644 index 00000000..49eac98b --- /dev/null +++ b/cmd/slackdump/internal/workspace/workspaceui/select.go @@ -0,0 +1,72 @@ +package workspaceui + +import ( + "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui" +) + +type SelectModel struct { + Selected string + + table table.Model + finished bool + style style +} + +func NewSelectModel(columns []table.Column, rows []table.Row) SelectModel { + t := table.New( + table.WithColumns(columns), + table.WithRows(rows), + table.WithFocused(true), + table.WithHeight(7), + ) + + s := table.Styles{ + Header: ui.DefaultTheme().Focused.Title.Padding(0, 1), + Selected: ui.DefaultTheme().Focused.SelectedLine.Bold(true), + Cell: ui.DefaultTheme().Focused.Text.Padding(0, 1), + } + t.SetStyles(s) + t.Focus() + return SelectModel{ + table: t, + style: style{ + FocusedBorder: ui.DefaultTheme().Focused.Border, + }, + } +} + +type style struct { + FocusedBorder lipgloss.Style +} + +func (m SelectModel) Init() tea.Cmd { return nil } + +func (m SelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + var cmds []tea.Cmd + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c", "esc": + m.finished = true + cmds = append(cmds, tea.Quit) + case "enter": + m.Selected = m.table.SelectedRow()[1] + m.finished = true + cmds = append(cmds, tea.Quit) + } + } + m.table, cmd = m.table.Update(msg) + cmds = append(cmds, cmd) + return m, tea.Batch(cmds...) +} + +func (m SelectModel) View() string { + if m.finished { + return "" // don't render the table if we've selected a workspace + } + return m.style.FocusedBorder.Render((m.table.View()) + "\n\n" + ui.HuhTheme().Help.Ellipsis.Render("Select the workspace with arrow keys, press [Enter] to confirm, [Esc] to cancel.")) +} diff --git a/cmd/slackdump/internal/workspace/workspaceui/workspaceui.go b/cmd/slackdump/internal/workspace/workspaceui/workspaceui.go index f4f14c33..dce11ddd 100644 --- a/cmd/slackdump/internal/workspace/workspaceui/workspaceui.go +++ b/cmd/slackdump/internal/workspace/workspaceui/workspaceui.go @@ -11,6 +11,8 @@ import ( "github.com/rusq/slackdump/v3/cmd/slackdump/internal/cfg" "github.com/rusq/slackdump/v3/cmd/slackdump/internal/golang/base" "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui/bubbles/menu" + "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui/cfgui" + "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui/updaters" "github.com/rusq/slackdump/v3/internal/cache" ) @@ -27,11 +29,12 @@ func WorkspaceNew(ctx context.Context, _ *base.Command, _ []string) error { // it will quit after the user has successfully authenticated. func ShowUI(ctx context.Context, quicklogin bool) error { const ( - actLogin = "ezlogin" - actToken = "token" - actTokenFile = "tokenfile" - actSecrets = "secrets" - actExit = "exit" + actLogin = "ezlogin" + actToken = "token" + actTokenFile = "tokenfile" + actSecrets = "secrets" + actBrowserOpts = "ezopts" + actExit = "exit" ) mgr, err := cache.NewManager(cfg.CacheDir()) @@ -39,12 +42,24 @@ func ShowUI(ctx context.Context, quicklogin bool) error { return err } + var brwsOpts browserOptions + items := []menu.Item{ { ID: actLogin, Name: "Login in Browser", Help: "Opens the browser and lets you login in a familiar way.", }, + { + ID: actBrowserOpts, + Name: "Browser Options", + Help: "Show browser options", + Preview: true, + Model: cfgui.NewConfigUI(cfgui.DefaultStyle(), configuration(&brwsOpts)), + }, + { + Separator: true, + }, { ID: actToken, Name: "Token/Cookie", @@ -55,6 +70,9 @@ func ShowUI(ctx context.Context, quicklogin bool) error { Name: "Token/Cookie from file", Help: "Provide token value and cookies from file", }, + { + Separator: true, + }, { ID: actSecrets, Name: "From file with secrets", @@ -72,7 +90,7 @@ func ShowUI(ctx context.Context, quicklogin bool) error { // new workspace methods var methods = map[string]func(context.Context, manager) error{ - actLogin: ezLogin3000, + actLogin: brwsLogin(&brwsOpts), actToken: prgTokenCookie, actTokenFile: prgTokenCookieFile, actSecrets: fileWithSecrets, @@ -117,3 +135,25 @@ type wizModel struct{ m *menu.Model } func (m *wizModel) Init() tea.Cmd { return m.m.Init() } func (m *wizModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.m.Update(msg) } func (m *wizModel) View() string { return m.m.View() } + +type browserOptions struct { + UsePlaywright bool +} + +func configuration(opts *browserOptions) func() cfgui.Configuration { + return func() cfgui.Configuration { + return cfgui.Configuration{ + { + Name: "EZ-Login options", + Params: []cfgui.Parameter{ + { + Name: "Use Playwright", + Description: "Use Playwright to automate the browser instead of Rod.", + Value: cfgui.Checkbox(opts.UsePlaywright), + Updater: updaters.NewBool(&opts.UsePlaywright), + }, + }, + }, + } + } +} diff --git a/cmd/slackdump/main.go b/cmd/slackdump/main.go index 79a5b263..4e27cdfd 100644 --- a/cmd/slackdump/main.go +++ b/cmd/slackdump/main.go @@ -12,7 +12,6 @@ import ( "strings" "github.com/charmbracelet/huh" - "github.com/joho/godotenv" "github.com/rusq/dlog" "github.com/rusq/tracer" "golang.org/x/term" @@ -39,7 +38,6 @@ import ( ) func init() { - loadSecrets(secretFiles) base.Slackdump.Commands = []*base.Command{ workspace.CmdWorkspace, @@ -285,19 +283,6 @@ func iftrue[T any](cond bool, t T, f T) T { return f } -// secrets defines the names of the supported secret files that we load our -// secrets from. Inexperienced Windows users might have bad experience trying -// to create .env file with the notepad as it will battle for having the -// "txt" extension. Let it have it. -var secretFiles = []string{".env", ".env.txt", "secrets.txt"} - -// loadSecrets load secrets from the files in secrets slice. -func loadSecrets(files []string) { - for _, f := range files { - _ = godotenv.Load(f) - } -} - type choice string const ( @@ -313,7 +298,7 @@ func whatDo() (choice, error) { var ans choice err := huh.NewForm(huh.NewGroup(huh.NewSelect[choice](). Title(versionstr). - Description("What do you want to do?"). + Description("What would you like to do?"). Options( huh.NewOption(string(choiceHelp), choiceHelp), huh.NewOption(string(choiceWizard), choiceWizard), From 0b10d741bf0d78c9aecd8fead24d5f0ce6294e21 Mon Sep 17 00:00:00 2001 From: Rustam Gilyazov <16064414+rusq@users.noreply.github.com> Date: Sat, 16 Nov 2024 22:37:48 +1000 Subject: [PATCH 5/8] workspace selection --- .../internal/workspace/wiz_select.go | 10 +++--- .../internal/workspace/workspaceui/select.go | 32 ++++++++++++++++--- internal/network/limits.go | 32 +++++++++---------- 3 files changed, 47 insertions(+), 27 deletions(-) diff --git a/cmd/slackdump/internal/workspace/wiz_select.go b/cmd/slackdump/internal/workspace/wiz_select.go index cd0f1201..11157f6b 100644 --- a/cmd/slackdump/internal/workspace/wiz_select.go +++ b/cmd/slackdump/internal/workspace/wiz_select.go @@ -23,14 +23,11 @@ func wizSelect(ctx context.Context, cmd *base.Command, args []string) error { return err } - sm, err := workspaceSelectModel(ctx, m) + sm, err := newWspSelectModel(ctx, m) if err != nil { return err } - if sm == nil { - // TODO: handle this case - return nil - } + mod, err := tea.NewProgram(sm).Run() if err != nil { return fmt.Errorf("workspace select wizard error: %w", err) @@ -46,7 +43,8 @@ func wizSelect(ctx context.Context, cmd *base.Command, args []string) error { return nil } -func workspaceSelectModel(ctx context.Context, m manager) (tea.Model, error) { +// newWspSelectModel creates a new workspace selection model. +func newWspSelectModel(ctx context.Context, m manager) (tea.Model, error) { wspList, err := m.List() if err != nil { if errors.Is(err, cache.ErrNoWorkspaces) { diff --git a/cmd/slackdump/internal/workspace/workspaceui/select.go b/cmd/slackdump/internal/workspace/workspaceui/select.go index 49eac98b..a38f53c3 100644 --- a/cmd/slackdump/internal/workspace/workspaceui/select.go +++ b/cmd/slackdump/internal/workspace/workspaceui/select.go @@ -1,6 +1,8 @@ package workspaceui import ( + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/table" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -13,6 +15,8 @@ type SelectModel struct { table table.Model finished bool style style + keymap selKeymap + help help.Model } func NewSelectModel(columns []table.Column, rows []table.Row) SelectModel { @@ -35,6 +39,8 @@ func NewSelectModel(columns []table.Column, rows []table.Row) SelectModel { style: style{ FocusedBorder: ui.DefaultTheme().Focused.Border, }, + keymap: defSelKeymap(), + help: help.New(), } } @@ -42,6 +48,24 @@ type style struct { FocusedBorder lipgloss.Style } +type selKeymap struct { + Select key.Binding + Delete key.Binding + Quit key.Binding +} + +func (k selKeymap) Bindings() []key.Binding { + return []key.Binding{k.Select, k.Delete, k.Quit} +} + +func defSelKeymap() selKeymap { + return selKeymap{ + Select: key.NewBinding(key.WithKeys("enter"), key.WithHelp("Enter", "Select")), + Delete: key.NewBinding(key.WithKeys("x", "delete"), key.WithHelp("del", "Delete")), + Quit: key.NewBinding(key.WithKeys("q", "ctrl+c", "esc"), key.WithHelp("esc", "Quit")), + } +} + func (m SelectModel) Init() tea.Cmd { return nil } func (m SelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -49,11 +73,11 @@ func (m SelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { case tea.KeyMsg: - switch msg.String() { - case "q", "ctrl+c", "esc": + switch { + case key.Matches(msg, m.keymap.Quit): m.finished = true cmds = append(cmds, tea.Quit) - case "enter": + case key.Matches(msg, m.keymap.Select): m.Selected = m.table.SelectedRow()[1] m.finished = true cmds = append(cmds, tea.Quit) @@ -68,5 +92,5 @@ func (m SelectModel) View() string { if m.finished { return "" // don't render the table if we've selected a workspace } - return m.style.FocusedBorder.Render((m.table.View()) + "\n\n" + ui.HuhTheme().Help.Ellipsis.Render("Select the workspace with arrow keys, press [Enter] to confirm, [Esc] to cancel.")) + return m.style.FocusedBorder.Render((m.table.View()) + "\n\n" + m.help.ShortHelpView(m.keymap.Bindings())) } diff --git a/internal/network/limits.go b/internal/network/limits.go index c8a4286c..3abe285b 100644 --- a/internal/network/limits.go +++ b/internal/network/limits.go @@ -1,8 +1,6 @@ package network import ( - "reflect" - "github.com/go-playground/locales/en" ut "github.com/go-playground/universal-translator" "github.com/go-playground/validator/v10" @@ -11,39 +9,39 @@ import ( type Limits struct { // number of file-saving workers - Workers int `json:"workers,omitempty" yaml:"workers,omitempty" validate:"gte=1,lte=128"` + Workers int `json:"workers,omitempty" yaml:"workers,omitempty" toml:"workers,omitempty" validate:"gte=1,lte=128"` // if we get rate limited on file downloads, this is how many times we're // going to retry - DownloadRetries int `json:"download_retries,omitempty" yaml:"download_retries,omitempty"` + DownloadRetries int `json:"download_retries,omitempty" yaml:"download_retries,omitempty" toml:"download_retries,omitempty"` // Tier-2 limits - Tier2 TierLimit `json:"tier_2,omitempty" yaml:"tier_2,omitempty"` + Tier2 TierLimit `json:"tier_2,omitempty" yaml:"tier_2,omitempty" toml:"tier_2,omitempty"` // Tier-3 limits - Tier3 TierLimit `json:"tier_3,omitempty" yaml:"tier_3,omitempty"` + Tier3 TierLimit `json:"tier_3,omitempty" yaml:"tier_3,omitempty" toml:"tier_3,omitempty"` // Tier-4 limits - Tier4 TierLimit `json:"tier_4,omitempty" yaml:"tier_4,omitempty"` + Tier4 TierLimit `json:"tier_4,omitempty" yaml:"tier_4,omitempty" toml:"tier_4,omitempty"` // Request Limits - Request RequestLimit `json:"per_request,omitempty" yaml:"per_request,omitempty"` + Request RequestLimit `json:"per_request,omitempty" yaml:"per_request,omitempty" toml:"per_request,omitempty"` } // TierLimit represents a Slack API Tier limits. type TierLimit struct { // Tier limiter boost - Boost uint `json:"boost,omitempty" yaml:"boost,omitempty"` + Boost uint `json:"boost,omitempty" yaml:"boost,omitempty" toml:"boost,omitempty"` // Tier limiter burst - Burst uint `json:"burst,omitempty" yaml:"burst,omitempty" validate:"gte=1"` + Burst uint `json:"burst,omitempty" yaml:"burst,omitempty" validate:"gte=1" toml:"burst,omitempty"` // Tier retries when getting transient errors, i.e. 429 or 500-599. - Retries int `json:"retries,omitempty" yaml:"retries,omitempty"` + Retries int `json:"retries,omitempty" yaml:"retries,omitempty" toml:"retries,omitempty" validate:"gte=1"` } // RequestLimit defines the limits on the requests that are sent to the API. type RequestLimit struct { // number of messages we get per 1 API request. bigger the number, fewer // requests, but they become more beefy. - Conversations int `json:"conversations,omitempty" yaml:"conversations,omitempty" validate:"gt=0,lte=100"` + Conversations int `json:"conversations,omitempty" yaml:"conversations,omitempty" validate:"gt=0,lte=100" toml:"conversations,omitempty"` // number of channels to fetch per 1 API request. - Channels int `json:"channels,omitempty" yaml:"channels,omitempty" validate:"gt=0,lte=1000"` + Channels int `json:"channels,omitempty" yaml:"channels,omitempty" validate:"gt=0,lte=1000" toml:"channels,omitempty"` // number of thread replies per request (slack default: 1000) - Replies int `json:"replies,omitempty" yaml:"replies,omitempty" validate:"gt=0,lte=1000"` + Replies int `json:"replies,omitempty" yaml:"replies,omitempty" validate:"gt=0,lte=1000" toml:"replies,omitempty"` } var DefLimits = Limits{ @@ -143,6 +141,6 @@ func apply[T comparable](this *T, other T) { } } -func isZero(a any) bool { - return a == reflect.Zero(reflect.TypeOf(a)).Interface() -} +// func isZero(a any) bool { +// return a == reflect.Zero(reflect.TypeOf(a)).Interface() +// } From 9ed0404ce573cd0d8b107d5f06fe41ee27dd649d Mon Sep 17 00:00:00 2001 From: Rustam Gilyazov <16064414+rusq@users.noreply.github.com> Date: Sun, 17 Nov 2024 08:49:05 +1000 Subject: [PATCH 6/8] workspace select --- .../internal/workspace/wiz_select.go | 44 ++++++----- .../workspaceui/{workspaceui.go => new.go} | 2 + .../internal/workspace/workspaceui/select.go | 76 +++++++++++++++---- 3 files changed, 87 insertions(+), 35 deletions(-) rename cmd/slackdump/internal/workspace/workspaceui/{workspaceui.go => new.go} (98%) diff --git a/cmd/slackdump/internal/workspace/wiz_select.go b/cmd/slackdump/internal/workspace/wiz_select.go index 11157f6b..45231e6b 100644 --- a/cmd/slackdump/internal/workspace/wiz_select.go +++ b/cmd/slackdump/internal/workspace/wiz_select.go @@ -44,42 +44,46 @@ func wizSelect(ctx context.Context, cmd *base.Command, args []string) error { } // newWspSelectModel creates a new workspace selection model. -func newWspSelectModel(ctx context.Context, m manager) (tea.Model, error) { - wspList, err := m.List() +func newWspSelectModel(ctx context.Context, m *cache.Manager) (tea.Model, error) { + _, err := m.List() if err != nil { if errors.Is(err, cache.ErrNoWorkspaces) { if err := workspaceui.ShowUI(ctx, true); err != nil { return nil, err } - // retry - wspList, err = m.List() - if err != nil { - return nil, err - } } else { base.SetExitStatus(base.SUserError) return nil, err } } - current, err := m.Current() - if err != nil { + if _, err := m.Current(); err != nil { base.SetExitStatus(base.SWorkspaceError) return nil, fmt.Errorf("error getting the current workspace: %s", err) } - columns := []table.Column{ - {Title: "C", Width: 1}, - {Title: "Name", Width: 14}, - {Title: "Team", Width: 15}, - {Title: "User", Width: 15}, - {Title: "Status", Width: 30}, - } + var refreshFn = func() (cols []table.Column, rows []table.Row, err error) { + cols = []table.Column{ + {Title: "C", Width: 1}, + {Title: "Name", Width: 14}, + {Title: "Team", Width: 15}, + {Title: "User", Width: 15}, + {Title: "Status", Width: 30}, + } - var rows []table.Row - for _, w := range wspInfo(ctx, m, current, wspList) { - rows = append(rows, table.Row{w[0], w[1], w[4], w[5], w[6]}) + wspList, err := m.List() + if err != nil { + return + } + current, err := m.Current() + if err != nil { + return + } + for _, w := range wspInfo(ctx, m, current, wspList) { + rows = append(rows, table.Row{w[0], w[1], w[4], w[5], w[6]}) + } + return cols, rows, nil } - return workspaceui.NewSelectModel(columns, rows), nil + return workspaceui.NewSelectModel(m, refreshFn), nil } diff --git a/cmd/slackdump/internal/workspace/workspaceui/workspaceui.go b/cmd/slackdump/internal/workspace/workspaceui/new.go similarity index 98% rename from cmd/slackdump/internal/workspace/workspaceui/workspaceui.go rename to cmd/slackdump/internal/workspace/workspaceui/new.go index dce11ddd..98c088db 100644 --- a/cmd/slackdump/internal/workspace/workspaceui/workspaceui.go +++ b/cmd/slackdump/internal/workspace/workspaceui/new.go @@ -19,6 +19,8 @@ import ( //go:generate mockgen -package workspaceui -destination=test_mock_manager.go -source api.go manager type manager interface { CreateAndSelect(ctx context.Context, p auth.Provider) (string, error) + Select(name string) error + Delete(name string) error } func WorkspaceNew(ctx context.Context, _ *base.Command, _ []string) error { diff --git a/cmd/slackdump/internal/workspace/workspaceui/select.go b/cmd/slackdump/internal/workspace/workspaceui/select.go index a38f53c3..6fa4411a 100644 --- a/cmd/slackdump/internal/workspace/workspaceui/select.go +++ b/cmd/slackdump/internal/workspace/workspaceui/select.go @@ -1,6 +1,8 @@ package workspaceui import ( + "strings" + "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/table" @@ -10,16 +12,22 @@ import ( ) type SelectModel struct { - Selected string + Selected string + m manager + refreshFn TableRefreshFunc table table.Model finished bool style style keymap selKeymap help help.Model + lastErr error } -func NewSelectModel(columns []table.Column, rows []table.Row) SelectModel { +type TableRefreshFunc func() ([]table.Column, []table.Row, error) + +func NewSelectModel(m manager, refreshFn TableRefreshFunc) SelectModel { + columns, rows, err := refreshFn() t := table.New( table.WithColumns(columns), table.WithRows(rows), @@ -35,23 +43,33 @@ func NewSelectModel(columns []table.Column, rows []table.Row) SelectModel { t.SetStyles(s) t.Focus() return SelectModel{ - table: t, + table: t, + m: m, + refreshFn: refreshFn, style: style{ FocusedBorder: ui.DefaultTheme().Focused.Border, + Title: ui.DefaultTheme().Focused.Title, + Description: ui.DefaultTheme().Focused.Description, + Error: ui.DefaultTheme().Error, }, - keymap: defSelKeymap(), - help: help.New(), + keymap: defSelKeymap(), + help: help.New(), + lastErr: err, } } type style struct { FocusedBorder lipgloss.Style + Title lipgloss.Style + Description lipgloss.Style + Error lipgloss.Style } type selKeymap struct { - Select key.Binding - Delete key.Binding - Quit key.Binding + Select key.Binding + Delete key.Binding + Quit key.Binding + Refresh key.Binding } func (k selKeymap) Bindings() []key.Binding { @@ -60,27 +78,45 @@ func (k selKeymap) Bindings() []key.Binding { func defSelKeymap() selKeymap { return selKeymap{ - Select: key.NewBinding(key.WithKeys("enter"), key.WithHelp("Enter", "Select")), - Delete: key.NewBinding(key.WithKeys("x", "delete"), key.WithHelp("del", "Delete")), - Quit: key.NewBinding(key.WithKeys("q", "ctrl+c", "esc"), key.WithHelp("esc", "Quit")), + Select: key.NewBinding(key.WithKeys("enter"), key.WithHelp("Enter", "Select")), + Delete: key.NewBinding(key.WithKeys("x", "delete"), key.WithHelp("del", "Delete")), + Quit: key.NewBinding(key.WithKeys("q", "ctrl+c", "esc"), key.WithHelp("esc", "Quit")), + Refresh: key.NewBinding(key.WithKeys("ctrl+r"), key.WithHelp("^r", "Refresh")), } } func (m SelectModel) Init() tea.Cmd { return nil } func (m SelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var refresh = func() { + columns, rows, err := m.refreshFn() + m.table.SetColumns(columns) + m.table.SetRows(rows) + m.lastErr = err + } + var cmd tea.Cmd var cmds []tea.Cmd switch msg := msg.(type) { case tea.KeyMsg: switch { - case key.Matches(msg, m.keymap.Quit): - m.finished = true - cmds = append(cmds, tea.Quit) case key.Matches(msg, m.keymap.Select): + if len(m.table.SelectedRow()) == 0 { + break + } m.Selected = m.table.SelectedRow()[1] + fallthrough + case key.Matches(msg, m.keymap.Quit): m.finished = true cmds = append(cmds, tea.Quit) + case key.Matches(msg, m.keymap.Delete): + if len(m.table.SelectedRow()) == 0 { + break + } + m.lastErr = m.m.Delete(m.table.SelectedRow()[1]) + refresh() + case key.Matches(msg, m.keymap.Refresh): + refresh() } } m.table, cmd = m.table.Update(msg) @@ -92,5 +128,15 @@ func (m SelectModel) View() string { if m.finished { return "" // don't render the table if we've selected a workspace } - return m.style.FocusedBorder.Render((m.table.View()) + "\n\n" + m.help.ShortHelpView(m.keymap.Bindings())) + var b strings.Builder + + b.WriteString(m.style.Title.Render("Slackdump Workspaces") + "\n") + b.WriteString(m.style.Description.Render("Select a workspace to work with") + "\n\n") + b.WriteString(m.table.View() + "\n") + if m.lastErr != nil { + b.WriteString(m.style.Error.Render(m.lastErr.Error()) + "\n") + } + b.WriteString(m.help.ShortHelpView(m.keymap.Bindings())) + + return m.style.FocusedBorder.Render(b.String()) } From b99701a7ba6c8efde550e2b714c7a4b18dfb06d5 Mon Sep 17 00:00:00 2001 From: Rustam Gilyazov <16064414+rusq@users.noreply.github.com> Date: Sun, 17 Nov 2024 13:21:40 +1000 Subject: [PATCH 7/8] explicit loadSecrets+some tidying up --- cmd/slackdump/internal/bootstrap/provider.go | 2 +- cmd/slackdump/internal/cfg/cfg.go | 10 +++--- .../internal/workspace/wiz_select.go | 33 ++++++++++--------- .../internal/workspace/workspaceui/new.go | 30 ++++++++++++++--- cmd/slackdump/main.go | 18 ++++++++++ downloader/deprecated.go | 4 +-- 6 files changed, 70 insertions(+), 27 deletions(-) diff --git a/cmd/slackdump/internal/bootstrap/provider.go b/cmd/slackdump/internal/bootstrap/provider.go index 241f0caa..f54bc0e4 100644 --- a/cmd/slackdump/internal/bootstrap/provider.go +++ b/cmd/slackdump/internal/bootstrap/provider.go @@ -18,7 +18,7 @@ func CurrentOrNewProviderCtx(ctx context.Context) (context.Context, error) { if err != nil { if errors.Is(err, cache.ErrNoWorkspaces) { // ask to create a new workspace - if err := workspaceui.ShowUI(ctx, true); err != nil { + if err := workspaceui.ShowUI(ctx, workspaceui.WithQuickLogin(), workspaceui.WithTitle("No workspaces, please choose a login method")); err != nil { return ctx, fmt.Errorf("auth error: %w", err) } // one more time... diff --git a/cmd/slackdump/internal/cfg/cfg.go b/cmd/slackdump/internal/cfg/cfg.go index b8a57cab..8abb5913 100644 --- a/cmd/slackdump/internal/cfg/cfg.go +++ b/cmd/slackdump/internal/cfg/cfg.go @@ -57,6 +57,10 @@ var ( Log logger.Interface + // LoadSecrets is a flag that indicates whether to load secrets from the + // environment variables. + LoadSecrets bool + Version BuildInfo // version propagated by main package. ) @@ -104,14 +108,14 @@ func SetBaseFlags(fs *flag.FlagSet, mask FlagMask) { if mask&OmitAuthFlags == 0 { fs.StringVar(&SlackToken, "token", osenv.Secret("SLACK_TOKEN", ""), "Slack `token`") - // COOKIE environment variable is deprecated and will be removed in v2.5.0, use SLACK_COOKIE instead. - fs.StringVar(&SlackCookie, "cookie", osenv.Secret("SLACK_COOKIE", osenv.Secret("COOKIE", "")), "d= cookie `value` or a path to a cookie.txt file\n(environment: SLACK_COOKIE)") + fs.StringVar(&SlackCookie, "cookie", osenv.Secret("SLACK_COOKIE", ""), "d= cookie `value` or a path to a cookie.txt file\n(environment: SLACK_COOKIE)") fs.Var(&Browser, "browser", "browser to use for legacy EZ-Login 3000 (default: firefox)") fs.DurationVar(&LoginTimeout, "browser-timeout", LoginTimeout, "Browser login `timeout`") fs.DurationVar(&HeadlessTimeout, "autologin-timeout", HeadlessTimeout, "headless autologin `timeout`, without the browser starting time, just the interaction time") fs.BoolVar(&LegacyBrowser, "legacy-browser", false, "use legacy browser automation (playwright) for EZ-Login 3000") fs.BoolVar(&ForceEnterprise, "enterprise", false, "enable Enteprise module, you need to specify this option if you're using Slack Enterprise Grid") fs.StringVar(&RODUserAgent, "user-agent", "", "override the user agent string for EZ-Login 3000") + fs.BoolVar(&LoadSecrets, "load-env", false, "load secrets from the .env, .env.txt or secrets.txt file") } if mask&OmitDownloadFlag == 0 { fs.BoolVar(&DownloadFiles, "files", true, "enables file attachments (to disable, specify: -files=false)") @@ -146,7 +150,5 @@ func SetBaseFlags(fs *flag.FlagSet, mask FlagMask) { if mask&OmitTimeframeFlag == 0 { fs.Var(&Oldest, "time-from", "timestamp of the oldest message to fetch (UTC timezone)") fs.Var(&Latest, "time-to", "timestamp of the newest message to fetch (UTC timezone)") - fs.Var(&Oldest, "date-from", "alias for -time-from (DEPRECATED)") - fs.Var(&Latest, "date-to", "alias for -time-to (DEPRECATED)") } } diff --git a/cmd/slackdump/internal/workspace/wiz_select.go b/cmd/slackdump/internal/workspace/wiz_select.go index 45231e6b..c4dcb874 100644 --- a/cmd/slackdump/internal/workspace/wiz_select.go +++ b/cmd/slackdump/internal/workspace/wiz_select.go @@ -23,6 +23,23 @@ func wizSelect(ctx context.Context, cmd *base.Command, args []string) error { return err } + if _, err := m.List(); err != nil { + if errors.Is(err, cache.ErrNoWorkspaces) { + if err := workspaceui.ShowUI(ctx, workspaceui.WithQuickLogin(), workspaceui.WithTitle("No workspaces, please choose the login method:")); err != nil { + return err + } + return nil + } else { + base.SetExitStatus(base.SUserError) + return err + } + } + + if _, err := m.Current(); err != nil { + base.SetExitStatus(base.SWorkspaceError) + return fmt.Errorf("error getting the current workspace: %s", err) + } + sm, err := newWspSelectModel(ctx, m) if err != nil { return err @@ -45,22 +62,6 @@ func wizSelect(ctx context.Context, cmd *base.Command, args []string) error { // newWspSelectModel creates a new workspace selection model. func newWspSelectModel(ctx context.Context, m *cache.Manager) (tea.Model, error) { - _, err := m.List() - if err != nil { - if errors.Is(err, cache.ErrNoWorkspaces) { - if err := workspaceui.ShowUI(ctx, true); err != nil { - return nil, err - } - } else { - base.SetExitStatus(base.SUserError) - return nil, err - } - } - - if _, err := m.Current(); err != nil { - base.SetExitStatus(base.SWorkspaceError) - return nil, fmt.Errorf("error getting the current workspace: %s", err) - } var refreshFn = func() (cols []table.Column, rows []table.Row, err error) { cols = []table.Column{ diff --git a/cmd/slackdump/internal/workspace/workspaceui/new.go b/cmd/slackdump/internal/workspace/workspaceui/new.go index 98c088db..a6dfeaa4 100644 --- a/cmd/slackdump/internal/workspace/workspaceui/new.go +++ b/cmd/slackdump/internal/workspace/workspaceui/new.go @@ -24,12 +24,27 @@ type manager interface { } func WorkspaceNew(ctx context.Context, _ *base.Command, _ []string) error { - return ShowUI(ctx, false) + return ShowUI(ctx) +} + +type options struct { + title string + quicklogin bool +} + +type UIOption func(*options) + +func WithTitle(title string) UIOption { + return func(o *options) { o.title = title } +} + +func WithQuickLogin() UIOption { + return func(o *options) { o.quicklogin = true } } // ShowUI shows the authentication menu. If quicklogin is set to true, // it will quit after the user has successfully authenticated. -func ShowUI(ctx context.Context, quicklogin bool) error { +func ShowUI(ctx context.Context, opts ...UIOption) error { const ( actLogin = "ezlogin" actToken = "token" @@ -44,6 +59,13 @@ func ShowUI(ctx context.Context, quicklogin bool) error { return err } + var uiOpts = options{ + title: "New Workspace", + } + for _, o := range opts { + o(&uiOpts) + } + var brwsOpts browserOptions items := []menu.Item{ @@ -101,7 +123,7 @@ func ShowUI(ctx context.Context, quicklogin bool) error { var lastID string = actLogin LOOP: for { - m := menu.New("New Workspace", items, true) + m := menu.New(uiOpts.title, items, uiOpts.quicklogin) m.Select(lastID) if _, err := tea.NewProgram(&wizModel{m: m}, tea.WithContext(ctx)).Run(); err != nil { return err @@ -123,7 +145,7 @@ LOOP: } return err } - if quicklogin { + if uiOpts.quicklogin { return nil } } diff --git a/cmd/slackdump/main.go b/cmd/slackdump/main.go index 4e27cdfd..1c9d3650 100644 --- a/cmd/slackdump/main.go +++ b/cmd/slackdump/main.go @@ -12,6 +12,7 @@ import ( "strings" "github.com/charmbracelet/huh" + "github.com/joho/godotenv" "github.com/rusq/dlog" "github.com/rusq/tracer" "golang.org/x/term" @@ -157,6 +158,10 @@ func invoke(cmd *base.Command, args []string) error { return err } } + if cfg.LoadSecrets { + // load secrets only if we're told to. + loadSecrets(secretFiles) + } // maybe start trace if err := initTrace(cfg.TraceFile); err != nil { @@ -283,6 +288,19 @@ func iftrue[T any](cond bool, t T, f T) T { return f } +// secrets defines the names of the supported secret files that we load our +// secrets from. Inexperienced Windows users might have bad experience trying +// to create .env file with the notepad as it will battle for having the +// "txt" extension. Let it have it. +var secretFiles = []string{".env", ".env.txt", "secrets.txt"} + +// loadSecrets load secrets from the files in secrets slice. +func loadSecrets(files []string) { + for _, f := range files { + _ = godotenv.Load(f) + } +} + type choice string const ( diff --git a/downloader/deprecated.go b/downloader/deprecated.go index 31727059..9fd5e6fa 100644 --- a/downloader/deprecated.go +++ b/downloader/deprecated.go @@ -26,7 +26,7 @@ const ( // ClientV1 is the instance of the downloader. // -// Deprecated: Use Client. +// Deprecated: Use [Client]. type ClientV1 struct { v2 *Client nameFn FilenameFunc @@ -90,7 +90,7 @@ func WithNameFunc(fn FilenameFunc) OptionV1 { // NewV1 initialises new file downloader. // -// Deprecated: use NewV2 instead. +// Deprecated: use [New] instead. func NewV1(client Downloader, fs fsadapter.FS, opts ...OptionV1) *ClientV1 { c := &ClientV1{ v2: New(client, fs), From 792b1b3a56f5de6bcccd3866f7fa53148f767a2d Mon Sep 17 00:00:00 2001 From: Rustam Gilyazov <16064414+rusq@users.noreply.github.com> Date: Sun, 17 Nov 2024 14:04:00 +1000 Subject: [PATCH 8/8] fix tests --- internal/network/limits.go | 2 +- internal/network/network_test.go | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/internal/network/limits.go b/internal/network/limits.go index 3abe285b..d4ad69ac 100644 --- a/internal/network/limits.go +++ b/internal/network/limits.go @@ -30,7 +30,7 @@ type TierLimit struct { // Tier limiter burst Burst uint `json:"burst,omitempty" yaml:"burst,omitempty" validate:"gte=1" toml:"burst,omitempty"` // Tier retries when getting transient errors, i.e. 429 or 500-599. - Retries int `json:"retries,omitempty" yaml:"retries,omitempty" toml:"retries,omitempty" validate:"gte=1"` + Retries int `json:"retries,omitempty" yaml:"retries,omitempty" toml:"retries,omitempty"` } // RequestLimit defines the limits on the requests that are sent to the API. diff --git a/internal/network/network_test.go b/internal/network/network_test.go index 57758b44..03fb385c 100644 --- a/internal/network/network_test.go +++ b/internal/network/network_test.go @@ -64,7 +64,7 @@ func dAbs(d time.Duration) time.Duration { return d } -func Test_withRetry(t *testing.T) { +func TestWithRetry(t *testing.T) { t.Parallel() type args struct { ctx context.Context @@ -166,6 +166,7 @@ func Test_withRetry(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() start := time.Now() if err := WithRetry(tt.args.ctx, tt.args.l, tt.args.maxAttempts, tt.args.fn); (err != nil) != tt.wantErr { t.Errorf("withRetry() error = %v, wantErr %v", err, tt.wantErr) @@ -179,6 +180,7 @@ func Test_withRetry(t *testing.T) { }) } t.Run("500 error handling", func(t *testing.T) { + t.Parallel() waitFn = func(attempt int) time.Duration { return 50 * time.Millisecond } defer func() { waitFn = cubicWait @@ -275,6 +277,7 @@ func Test_withRetry(t *testing.T) { } func Test_cubicWait(t *testing.T) { + t.Parallel() type args struct { attempt int } @@ -293,6 +296,7 @@ func Test_cubicWait(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() if got := cubicWait(tt.args.attempt); !reflect.DeepEqual(got, tt.want) { t.Errorf("waitTime() = %v, want %v", got, tt.want) } @@ -301,6 +305,7 @@ func Test_cubicWait(t *testing.T) { } func Test_isRecoverable(t *testing.T) { + t.Parallel() type args struct { statusCode int } @@ -323,6 +328,7 @@ func Test_isRecoverable(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() if got := isRecoverable(tt.args.statusCode); got != tt.want { t.Errorf("isRecoverable() = %v, want %v", got, tt.want) }