diff --git a/auth/auth.go b/auth/auth.go index 2a4e8d20..24f4fb89 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -1,35 +1,46 @@ package auth import ( + "context" "encoding/json" "errors" "io" "net/http" + "runtime/trace" "strings" + + "github.com/rusq/chttp" + "github.com/slack-go/slack" ) // Type is the auth type. +// +//go:generate stringer -type Type -linecomment type Type uint8 // All supported auth types. const ( - TypeInvalid Type = iota - TypeValue - TypeCookieFile - TypeBrowser + TypeInvalid Type = iota // Invalid + TypeValue // Value + TypeCookieFile // Cookie File + TypeBrowser // EZ-Login 3000 ) // Provider is the Slack Authentication provider. +// +//go:generate mockgen -destination ../internal/mocks/mock_auth/mock_auth.go github.com/rusq/slackdump/v2/auth Provider type Provider interface { // SlackToken should return the Slack Token value. SlackToken() string - // Cookies should returns a set of Slack Session cookies. - Cookies() []http.Cookie + // Cookies should return a set of Slack Session cookies. + Cookies() []*http.Cookie // Type returns the auth type. Type() Type // Validate should return error, in case the token or cookies cannot be // retrieved. Validate() error + // Test tests if credentials are valid. + Test(ctx context.Context) error } var ( @@ -39,7 +50,7 @@ var ( type simpleProvider struct { Token string - Cookie []http.Cookie + Cookie []*http.Cookie } func (c simpleProvider) Validate() error { @@ -56,19 +67,10 @@ func (c simpleProvider) SlackToken() string { return c.Token } -func (c simpleProvider) Cookies() []http.Cookie { +func (c simpleProvider) Cookies() []*http.Cookie { return c.Cookie } -// deref dereferences []*T to []T. -func deref[T any](cc []*T) []T { - var ret = make([]T, len(cc)) - for i := range cc { - ret[i] = *cc[i] - } - return ret -} - // Load deserialises JSON data from reader and returns a ValueAuth, that can // be used to authenticate Slackdump. It will return ErrNoToken or // ErrNoCookie if the authentication information is missing. @@ -88,7 +90,7 @@ func Save(w io.Writer, p Provider) error { return err } - var s = simpleProvider{ + s := simpleProvider{ Token: p.SlackToken(), Cookie: p.Cookies(), } @@ -101,6 +103,27 @@ func Save(w io.Writer, p Provider) error { return nil } +// IsClientToken returns true if the tok is a web-client token. func IsClientToken(tok string) bool { return strings.HasPrefix(tok, "xoxc-") } + +// TestAuth attempts to authenticate with the given provider. It will return +// AuthError if failed. +func (s simpleProvider) Test(ctx context.Context) error { + ctx, task := trace.NewTask(ctx, "TestAuth") + defer task.End() + + httpCl, err := chttp.New("https://slack.com", s.Cookies()) + if err != nil { + return err + } + cl := slack.New(s.Token, slack.OptionHTTPClient(httpCl)) + + region := trace.StartRegion(ctx, "AuthTestContext") + defer region.End() + if _, err := cl.AuthTestContext(ctx); err != nil { + return &Error{Err: err} + } + return nil +} diff --git a/auth/auth_error.go b/auth/auth_error.go new file mode 100644 index 00000000..d4a137f9 --- /dev/null +++ b/auth/auth_error.go @@ -0,0 +1,21 @@ +package auth + +import "fmt" + +// Error is the error returned by New, the underlying Err contains +// an API error returned by slack.AuthTest call. +type Error struct { + Err error +} + +func (ae *Error) Error() string { + return fmt.Sprintf("failed to authenticate: %s", ae.Err) +} + +func (ae *Error) Unwrap() error { + return ae.Err +} + +func (ae *Error) Is(target error) bool { + return target == ae.Err +} diff --git a/auth/auth_error_test.go b/auth/auth_error_test.go new file mode 100644 index 00000000..088325d2 --- /dev/null +++ b/auth/auth_error_test.go @@ -0,0 +1,79 @@ +package auth + +import ( + "errors" + "fmt" + "testing" +) + +var errSample = errors.New("test error") + +func TestAuthError_Unwrap(t *testing.T) { + type fields struct { + Err error + } + tests := []struct { + name string + fields fields + wantErr error + }{ + { + "unwrap unwraps properly", + fields{Err: errSample}, + errSample, + }, + { + "multilevel wrap", + fields{Err: fmt.Errorf("blah: %w", errSample)}, + errSample, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ae := &Error{ + Err: tt.fields.Err, + } + if err := ae.Unwrap(); (err != nil) && !errors.Is(err, tt.wantErr) { + t.Errorf("AuthError.Unwrap() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestAuthError_Is(t *testing.T) { + type fields struct { + Err error + } + type args struct { + target error + } + tests := []struct { + name string + fields fields + args args + want bool + }{ + { + "is correctly compares underlying error", + fields{Err: errSample}, + args{errSample}, + true, + }, + { + "not matching error returns false", + fields{Err: errors.New("not me bro")}, + args{errSample}, + false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ae := &Error{ + Err: tt.fields.Err, + } + if got := ae.Is(tt.args.target); got != tt.want { + t.Errorf("AuthError.Is() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/auth/auth_test.go b/auth/auth_test.go index b2812feb..b997edb0 100644 --- a/auth/auth_test.go +++ b/auth/auth_test.go @@ -22,7 +22,7 @@ func TestLoad(t *testing.T) { { "loads valid data", args{strings.NewReader(`{"Token":"token_value","Cookie":[{"Name":"d","Value":"abc","Path":"","Domain":"","Expires":"0001-01-01T00:00:00Z","RawExpires":"","MaxAge":0,"Secure":false,"HttpOnly":false,"SameSite":0,"Raw":"","Unparsed":null}]}`)}, - ValueAuth{simpleProvider{Token: "token_value", Cookie: []http.Cookie{ + ValueAuth{simpleProvider{Token: "token_value", Cookie: []*http.Cookie{ {Name: "d", Value: "abc"}, }}}, false, @@ -66,7 +66,7 @@ func TestSave(t *testing.T) { }{ { "all info present", - args{ValueAuth{simpleProvider{Token: "token_value", Cookie: []http.Cookie{ + args{ValueAuth{simpleProvider{Token: "token_value", Cookie: []*http.Cookie{ {Name: "d", Value: "abc"}, }}}}, `{"Token":"token_value","Cookie":[{"Name":"d","Value":"abc","Path":"","Domain":"","Expires":"0001-01-01T00:00:00Z","RawExpires":"","MaxAge":0,"Secure":false,"HttpOnly":false,"SameSite":0,"Raw":"","Unparsed":null}]}` + "\n", @@ -74,7 +74,7 @@ func TestSave(t *testing.T) { }, { "token missing", - args{ValueAuth{simpleProvider{Token: "", Cookie: []http.Cookie{ + args{ValueAuth{simpleProvider{Token: "", Cookie: []*http.Cookie{ {Name: "d", Value: "abc"}, }}}}, "", @@ -82,13 +82,13 @@ func TestSave(t *testing.T) { }, { "cookies missing on client token", - args{ValueAuth{simpleProvider{Token: "xoxc-blah", Cookie: []http.Cookie{}}}}, + args{ValueAuth{simpleProvider{Token: "xoxc-blah", Cookie: []*http.Cookie{}}}}, "", true, }, { "cookies missing on non-client token", - args{ValueAuth{simpleProvider{Token: "xoxp-blah", Cookie: []http.Cookie{}}}}, + args{ValueAuth{simpleProvider{Token: "xoxp-blah", Cookie: []*http.Cookie{}}}}, `{"Token":"xoxp-blah","Cookie":[]} `, false, diff --git a/auth/browser.go b/auth/browser.go index 3477475f..843509ad 100644 --- a/auth/browser.go +++ b/auth/browser.go @@ -16,8 +16,13 @@ var defaultFlow = &auth_ui.Survey{} type BrowserAuth struct { simpleProvider - flow BrowserAuthUI + opts browserOpts +} + +type browserOpts struct { workspace string + browser browser.Browser + flow BrowserAuthUI } type BrowserAuthUI interface { @@ -25,46 +30,32 @@ type BrowserAuthUI interface { Stop() } -type BrowserOption func(*BrowserAuth) - -func BrowserWithAuthFlow(flow BrowserAuthUI) BrowserOption { - return func(ba *BrowserAuth) { - if flow == nil { - return - } - ba.flow = flow - } -} - -func BrowserWithWorkspace(name string) BrowserOption { - return func(ba *BrowserAuth) { - ba.workspace = name - } -} - -func NewBrowserAuth(ctx context.Context, opts ...BrowserOption) (BrowserAuth, error) { +func NewBrowserAuth(ctx context.Context, opts ...Option) (BrowserAuth, error) { var br = BrowserAuth{ - flow: defaultFlow, + opts: browserOpts{ + flow: defaultFlow, + browser: browser.Bfirefox, + }, } for _, opt := range opts { - opt(&br) + opt(&options{browserOpts: &br.opts}) } - if br.workspace == "" { + if br.opts.workspace == "" { var err error - br.workspace, err = br.flow.RequestWorkspace(os.Stdout) + br.opts.workspace, err = br.opts.flow.RequestWorkspace(os.Stdout) if err != nil { return br, err } - defer br.flow.Stop() + defer br.opts.flow.Stop() } - if wsp, err := sanitize(br.workspace); err != nil { + if wsp, err := sanitize(br.opts.workspace); err != nil { return br, err } else { - br.workspace = wsp + br.opts.workspace = wsp } - auther, err := browser.New(br.workspace) + auther, err := browser.New(br.opts.workspace, browser.OptBrowser(br.opts.browser)) if err != nil { return br, err } diff --git a/auth/browser/browser.go b/auth/browser/browser.go index 13474cef..5247fd0f 100644 --- a/auth/browser/browser.go +++ b/auth/browser/browser.go @@ -1,175 +1,55 @@ package browser import ( - "context" - "errors" "fmt" - "net/http" - "runtime/trace" - "time" + "strings" "github.com/playwright-community/playwright-go" - "github.com/rusq/slackdump/v2/logger" ) -const slackDomain = ".slack.com" +//go:generate stringer -type Browser -trimprefix=B browser.go +type Browser int -// Client is the client for Browser Auth Provider. -type Client struct { - workspace string - pageClosed chan bool // will receive a notification that the page is closed prematurely. -} - -var Logger logger.Interface = logger.Default - -// New create new browser based client -func New(workspace string) (*Client, error) { - if workspace == "" { - return nil, errors.New("workspace can't be empty") - } - if err := playwright.Install(&playwright.RunOptions{ - Browsers: []string{"firefox"}, - }); err != nil { - return nil, err - } - return &Client{workspace: workspace, pageClosed: make(chan bool, 1)}, nil -} - -func (cl *Client) Authenticate(ctx context.Context) (string, []http.Cookie, error) { - - ctx, task := trace.NewTask(ctx, "Authenticate") - defer task.End() - - pw, err := playwright.Run() - if err != nil { - return "", nil, err - } - defer pw.Stop() - - opts := playwright.BrowserTypeLaunchOptions{ - Headless: playwright.Bool(false), - } - browser, err := pw.Firefox.Launch(opts) - if err != nil { - return "", nil, err - } - defer browser.Close() - - context, err := browser.NewContext() - if err != nil { - return "", nil, err - } - defer context.Close() - - var ( - _s = playwright.String - _f = playwright.Float - ) - if err := context.AddCookies(playwright.BrowserContextAddCookiesOptionsCookies{ - Domain: _s(slackDomain), - Path: _s("/"), - Name: _s("OptanonAlertBoxClosed"), - Value: _s(time.Now().Add(-10 * time.Minute).Format(time.RFC3339)), - Expires: _f(float64(time.Now().AddDate(0, 0, 30).Unix())), - }); err != nil { - return "", nil, err - } - - page, err := context.NewPage() - if err != nil { - return "", nil, err - } - page.On("close", func() { trace.Log(ctx, "user", "page closed"); close(cl.pageClosed) }) - - uri := fmt.Sprintf("https://%s"+slackDomain, cl.workspace) - l().Debugf("opening browser URL=%s", uri) - - if _, err := page.Goto(uri); err != nil { - return "", nil, err - } - - var r playwright.Request - if err := cl.withBrowserGuard(ctx, func() { - r = page.WaitForRequest(uri + "/api/api.features*") - }); err != nil { - return "", nil, err - } - - token, err := extractToken(r) - if err != nil { - return "", nil, err - } - - state, err := context.StorageState() - if err != nil { - return "", nil, err - } - if len(state.Cookies) == 0 { - return "", nil, errors.New("empty cookies") - } +const ( + Bfirefox Browser = iota + Bchromium +) - return token, convertCookies(state.Cookies), nil -} +type Option func(*Client) -func (cl *Client) withBrowserGuard(ctx context.Context, fn func()) error { - var done = make(chan struct{}) - go func() { - defer close(done) - fn() - }() - select { - case <-ctx.Done(): - return ctx.Err() - case <-cl.pageClosed: - return errors.New("browser closed") - case <-done: +func OptBrowser(b Browser) Option { + return func(c *Client) { + if b < Bfirefox || Bchromium < b { + b = Bfirefox + } + c.br = b } - return nil } -func convertCookies(pwc []playwright.Cookie) []http.Cookie { - var ret = make([]http.Cookie, 0, len(pwc)) - for _, p := range pwc { - ret = append(ret, http.Cookie{ - Name: p.Name, - Value: p.Value, - Path: p.Path, - Domain: p.Domain, - Expires: float2time(p.Expires), - MaxAge: 0, - Secure: p.Secure, - HttpOnly: p.HttpOnly, - SameSite: sameSite(p.SameSite), - }) +func (e *Browser) Set(v string) error { + v = strings.ToLower(v) + for i := 0; i < len(_Browser_index)-1; i++ { + if strings.ToLower(_Browser_name[_Browser_index[i]:_Browser_index[i+1]]) == v { + *e = Browser(i) + return nil + } } - return ret -} - -var str2samesite = map[string]http.SameSite{ - "": http.SameSiteDefaultMode, - "Lax": http.SameSiteLaxMode, - "None": http.SameSiteNoneMode, - "Strict": http.SameSiteStrictMode, -} - -// sameSite returns the constant value that maps to the string value of SameSite. -func sameSite(val string) http.SameSite { - return str2samesite[val] -} - -// float2time converts a float value of Unix time to time, nanoseconds value -// is discarded. If v == -1, it returns the date approximately 5 years from -// Now(). -func float2time(v float64) time.Time { - if v == -1.0 { - return time.Now().Add(5 * 365 * 24 * time.Hour) + var allowed []string + for i := 0; i < len(_Browser_index)-1; i++ { + allowed = append(allowed, _Browser_name[_Browser_index[i]:_Browser_index[i+1]]) } - return time.Unix(int64(v), 0) + return fmt.Errorf("unknown browser: %s, allowed: %v", v, allowed) } -func l() logger.Interface { - if Logger == nil { - return logger.Default +// client returns the appropriate client from playwright.Playwright. +func (br Browser) client(pw *playwright.Playwright) playwright.BrowserType { + switch br { + default: + fallthrough + case Bfirefox: + return pw.Firefox + case Bchromium: + return pw.Chromium } - return Logger + // unreachable } diff --git a/auth/browser/browser_string.go b/auth/browser/browser_string.go new file mode 100644 index 00000000..d57866b9 --- /dev/null +++ b/auth/browser/browser_string.go @@ -0,0 +1,24 @@ +// Code generated by "stringer -type Browser -trimprefix=B browser.go"; DO NOT EDIT. + +package browser + +import "strconv" + +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[Bfirefox-0] + _ = x[Bchromium-1] +} + +const _Browser_name = "firefoxchromium" + +var _Browser_index = [...]uint8{0, 7, 15} + +func (i Browser) String() string { + if i < 0 || i >= Browser(len(_Browser_index)-1) { + return "Browser(" + strconv.FormatInt(int64(i), 10) + ")" + } + return _Browser_name[_Browser_index[i]:_Browser_index[i+1]] +} diff --git a/auth/browser/browser_test.go b/auth/browser/browser_test.go deleted file mode 100644 index fd77b1e4..00000000 --- a/auth/browser/browser_test.go +++ /dev/null @@ -1,28 +0,0 @@ -package browser - -import ( - "reflect" - "testing" - "time" -) - -func Test_float2time(t *testing.T) { - type args struct { - v float64 - } - tests := []struct { - name string - args args - want time.Time - }{ - {"ok", args{1.68335956e+09}, time.Unix(1683359560, 0)}, - {"stripped", args{1.6544155598311e+09}, time.Unix(1654415559, 0)}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := float2time(tt.args.v); !reflect.DeepEqual(got, tt.want) { - t.Errorf("float2time() = %v, want %v", got, tt.want) - } - }) - } -} diff --git a/auth/browser/client.go b/auth/browser/client.go new file mode 100644 index 00000000..65f63a20 --- /dev/null +++ b/auth/browser/client.go @@ -0,0 +1,252 @@ +package browser + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "runtime" + "runtime/trace" + "strings" + "time" + + "github.com/playwright-community/playwright-go" + "github.com/rusq/slackdump/v2/logger" +) + +const slackDomain = ".slack.com" + +// Client is the client for Browser Auth Provider. +type Client struct { + workspace string + pageClosed chan bool // will receive a notification that the page is closed prematurely. + br Browser +} + +var Logger logger.Interface = logger.Default + +var installFn = playwright.Install + +// New create new browser based client. +func New(workspace string, opts ...Option) (*Client, error) { + if workspace == "" { + return nil, errors.New("workspace can't be empty") + } + cl := &Client{workspace: workspace, pageClosed: make(chan bool, 1), br: Bfirefox} + for _, opt := range opts { + opt(cl) + } + if err := installFn(&playwright.RunOptions{ + Browsers: []string{cl.br.String()}, + }); err != nil { + if !strings.Contains(err.Error(), "could not run driver") || runtime.GOOS == "windows" { + return nil, err + } + if err := pwRepair(cl.br.String()); err != nil { + return nil, err + } + } + return cl, nil +} + +func (cl *Client) Authenticate(ctx context.Context) (string, []*http.Cookie, error) { + ctx, task := trace.NewTask(ctx, "Authenticate") + defer task.End() + + var ( + _s = playwright.String + _f = playwright.Float + _b = playwright.Bool + ) + + pw, err := playwright.Run() + if err != nil { + return "", nil, err + } + defer pw.Stop() + + opts := playwright.BrowserTypeLaunchOptions{ + Headless: _b(false), + } + + browser, err := cl.br.client(pw).Launch(opts) + if err != nil { + return "", nil, err + } + defer browser.Close() + + context, err := browser.NewContext() + if err != nil { + return "", nil, err + } + defer context.Close() + + // disable the "cookies" nag screen. + if err := context.AddCookies(playwright.BrowserContextAddCookiesOptionsCookies{ + Domain: _s(slackDomain), + Path: _s("/"), + Name: _s("OptanonAlertBoxClosed"), + Value: _s(time.Now().Add(-10 * time.Minute).Format(time.RFC3339)), + Expires: _f(float64(time.Now().AddDate(0, 0, 30).Unix())), + }); err != nil { + return "", nil, err + } + + page, err := context.NewPage() + if err != nil { + return "", nil, err + } + page.On("close", func() { trace.Log(ctx, "user", "page closed"); close(cl.pageClosed) }) + + uri := fmt.Sprintf("https://%s"+slackDomain, cl.workspace) + l().Debugf("opening browser URL=%s", uri) + + if _, err := page.Goto(uri); err != nil { + return "", nil, err + } + + var r playwright.Request + if err := cl.withBrowserGuard(ctx, func() { + r = page.WaitForRequest(uri + "/api/api.features*") + }); err != nil { + return "", nil, err + } + + token, err := extractToken(r) + if err != nil { + return "", nil, err + } + + state, err := context.StorageState() + if err != nil { + return "", nil, err + } + if len(state.Cookies) == 0 { + return "", nil, errors.New("empty cookies") + } + + return token, convertCookies(state.Cookies), nil +} + +func (cl *Client) withBrowserGuard(ctx context.Context, fn func()) error { + done := make(chan struct{}) + go func() { + defer close(done) + fn() + }() + select { + case <-ctx.Done(): + return ctx.Err() + case <-cl.pageClosed: + return errors.New("browser closed or timed out") + case <-done: + } + return nil +} + +func convertCookies(pwc []playwright.Cookie) []*http.Cookie { + ret := make([]*http.Cookie, 0, len(pwc)) + for _, p := range pwc { + ret = append(ret, &http.Cookie{ + Name: p.Name, + Value: p.Value, + Path: p.Path, + Domain: p.Domain, + Expires: float2time(p.Expires), + MaxAge: 0, + Secure: p.Secure, + HttpOnly: p.HttpOnly, + SameSite: sameSite(p.SameSite), + }) + } + return ret +} + +var str2samesite = map[string]http.SameSite{ + "": http.SameSiteDefaultMode, + "Lax": http.SameSiteLaxMode, + "None": http.SameSiteNoneMode, + "Strict": http.SameSiteStrictMode, +} + +// sameSite returns the constant value that maps to the string value of SameSite. +func sameSite(val string) http.SameSite { + return str2samesite[val] +} + +// float2time converts a float value of Unix time to time, nanoseconds value +// is discarded. If v == -1, it returns the date approximately 5 years from +// Now(). +func float2time(v float64) time.Time { + if v == -1.0 { + return time.Now().Add(5 * 365 * 24 * time.Hour) + } + return time.Unix(int64(v), 0) +} + +func l() logger.Interface { + if Logger == nil { + return logger.Default + } + return Logger +} + +// newDriverFn is the function that creates a new driver. It is set to +// playwright.NewDriver by default, but can be overridden for testing. +var newDriverFn = playwright.NewDriver + +// pwRepair attempts to repair the playwright installation. +func pwRepair(browser string) error { + if browser == "" { + return nil + } + drv, err := newDriverFn(&playwright.RunOptions{ + Browsers: []string{browser}, + }) + if err != nil { + return err + } + + // check node permissions + if err := pwIsKnownProblem(drv.DriverDirectory); err != nil { + return err + } + if err := os.RemoveAll(drv.DriverDirectory); err != nil { + return err + } + + // attempt to reinstall + if err := installFn(&playwright.RunOptions{ + Browsers: []string{browser}, + }); err != nil { + // we did everything we could, but it still failed. + return err + } + return nil +} + +var errUnknownProblem = errors.New("unknown problem") + +// pwIsKnownProblem checks if the playwright installation is in a known +// problematic state, and if yes, return nil. If the problem is unknown, +// returns an errUnknownProblem. +func pwIsKnownProblem(path string) error { + if runtime.GOOS == "windows" { + // this should not ever happen on windows, as this problem relates to + // executable flag not being set, which is not a thing in a + // DOS/Windows world. + return errors.New("impossible has just happened, call the exorcist") + } + fi, err := os.Stat(filepath.Join(path, "node")) + if err != nil { + return err + } + // check if the file is executable, and if yes, return an error, because + // we wouldn't know what to do. + if fi.Mode()&0o111 != 0 { + return errUnknownProblem + } + return nil +} diff --git a/auth/browser/client_test.go b/auth/browser/client_test.go new file mode 100644 index 00000000..965ca567 --- /dev/null +++ b/auth/browser/client_test.go @@ -0,0 +1,108 @@ +package browser + +import ( + "errors" + "io/fs" + "os" + "path/filepath" + "reflect" + "runtime" + "testing" + "time" + + "github.com/playwright-community/playwright-go" +) + +func Test_float2time(t *testing.T) { + type args struct { + v float64 + } + tests := []struct { + name string + args args + want time.Time + }{ + {"ok", args{1.68335956e+09}, time.Unix(1683359560, 0)}, + {"stripped", args{1.6544155598311e+09}, time.Unix(1654415559, 0)}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := float2time(tt.args.v); !reflect.DeepEqual(got, tt.want) { + t.Errorf("float2time() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_pwRepair(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("skipping test on windows") + } + t.Run("known executable permissions problem causes reinstall", func(t *testing.T) { + baseDir := t.TempDir() + fakePwDir := filepath.Join(baseDir, "playwright-99.20.0") + + // installCalledi should be set to true if the install function is + // called. + installCalled := false + // set the mock install functions. + oldInstall := installFn + defer func() { installFn = oldInstall }() + installFn = func(...*playwright.RunOptions) error { + installCalled = true + return nil + } + oldNewDriverFn := newDriverFn + defer func() { newDriverFn = oldNewDriverFn }() + newDriverFn = func(*playwright.RunOptions) (*playwright.PlaywrightDriver, error) { + return &playwright.PlaywrightDriver{ + DriverDirectory: fakePwDir, + }, nil + } + + // create a fake node file with the wrong permissions. + makeFakeNode(t, fakePwDir, 0o644) + // run the repair function. + if err := pwRepair(baseDir); err != nil { + t.Fatal(err) + } + + if !installCalled { + t.Fatal("install was not called") + } + // check that the directory was removed + if _, err := os.Stat(fakePwDir); !os.IsNotExist(err) { + t.Fatal("directory was not removed") + } + }) +} + +func makeFakeNode(t *testing.T, dir string, mode fs.FileMode) { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "node"), []byte("hello"), mode); err != nil { + t.Fatal(err) + } +} + +func Test_pwIsKnownProblem(t *testing.T) { + t.Run("known executable permissions problem", func(t *testing.T) { + baseDir := t.TempDir() + makeFakeNode(t, baseDir, 0o644) + if err := pwIsKnownProblem(baseDir); err != nil { + t.Fatal(err) + } + }) + t.Run("other problem", func(t *testing.T) { + baseDir := t.TempDir() + makeFakeNode(t, baseDir, 0o755) + err := pwIsKnownProblem(baseDir) + if err == nil { + t.Fatal("unexpected success") + } + if !errors.Is(err, errUnknownProblem) { + t.Fatal("unexpected error") + } + }) +} diff --git a/auth/context.go b/auth/context.go new file mode 100644 index 00000000..bed91736 --- /dev/null +++ b/auth/context.go @@ -0,0 +1,26 @@ +package auth + +import ( + "context" + "errors" +) + +type ctxKey int + +const providerKey ctxKey = 0 + +var ErrNoProvider = errors.New("internal error: no provider in context") + +// FromContext returns the auth provider from the context. +func FromContext(ctx context.Context) (Provider, error) { + prov, ok := ctx.Value(providerKey).(Provider) + if !ok { + return nil, ErrNoProvider + } + return prov, nil +} + +// WithContext returns context with auth provider. +func WithContext(pctx context.Context, p Provider) context.Context { + return context.WithValue(pctx, providerKey, p) +} diff --git a/auth/context_test.go b/auth/context_test.go new file mode 100644 index 00000000..048919f1 --- /dev/null +++ b/auth/context_test.go @@ -0,0 +1,91 @@ +package auth + +import ( + "context" + "reflect" + "testing" +) + +type fakeProvider struct { + simpleProvider +} + +func (fakeProvider) Type() Type { + return Type(99) +} + +var fakeTestProvider = &fakeProvider{simpleProvider{Token: "test"}} + +var ( + emptyContext = context.Background() + contextWithProvider = WithContext(context.Background(), fakeTestProvider) +) + +func TestFromContext(t *testing.T) { + type args struct { + ctx context.Context + } + tests := []struct { + name string + args args + want Provider + wantErr bool + }{ + { + "empty context", + args{emptyContext}, + nil, + true, + }, + { + "context with provider", + args{contextWithProvider}, + &fakeProvider{simpleProvider{Token: "test"}}, + false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := FromContext(tt.args.ctx) + if (err != nil) != tt.wantErr { + t.Errorf("FromContext() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("FromContext() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestWithContext(t *testing.T) { + type args struct { + pctx context.Context + p Provider + } + tests := []struct { + name string + args args + want context.Context + }{ + { + "fake provider", + args{context.Background(), fakeTestProvider}, + contextWithProvider, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := WithContext(tt.args.pctx, tt.args.p); !reflect.DeepEqual(got, tt.want) { + t.Errorf("WithContext() = %v, want %v", got, tt.want) + } + prov, err := FromContext(tt.want) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(prov, tt.args.p) { + t.Errorf("Provider from context = %v, want %v", prov, tt.args.p) + } + }) + } +} diff --git a/auth/file.go b/auth/file.go index 56afdc93..40247299 100644 --- a/auth/file.go +++ b/auth/file.go @@ -22,7 +22,7 @@ func NewCookieFileAuth(token string, cookieFile string) (CookieFileAuth, error) fc := CookieFileAuth{ simpleProvider: simpleProvider{ Token: token, - Cookie: deref(ptrCookies), + Cookie: ptrCookies, }, } return fc, nil diff --git a/auth/option.go b/auth/option.go new file mode 100644 index 00000000..6ea13ed4 --- /dev/null +++ b/auth/option.go @@ -0,0 +1,30 @@ +package auth + +import "github.com/rusq/slackdump/v2/auth/browser" + +type options struct { + *browserOpts +} + +type Option func(*options) + +func BrowserWithAuthFlow(flow BrowserAuthUI) Option { + return func(o *options) { + if flow == nil { + return + } + o.browserOpts.flow = flow + } +} + +func BrowserWithWorkspace(name string) Option { + return func(o *options) { + o.browserOpts.workspace = name + } +} + +func BrowserWithBrowser(b browser.Browser) Option { + return func(o *options) { + o.browserOpts.browser = b + } +} diff --git a/auth/type_string.go b/auth/type_string.go new file mode 100644 index 00000000..fffecdf3 --- /dev/null +++ b/auth/type_string.go @@ -0,0 +1,26 @@ +// Code generated by "stringer -type Type -linecomment"; DO NOT EDIT. + +package auth + +import "strconv" + +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[TypeInvalid-0] + _ = x[TypeValue-1] + _ = x[TypeCookieFile-2] + _ = x[TypeBrowser-3] +} + +const _Type_name = "InvalidValueCookie FileEZ-Login 3000" + +var _Type_index = [...]uint8{0, 7, 12, 23, 36} + +func (i Type) String() string { + if i >= Type(len(_Type_index)-1) { + return "Type(" + strconv.FormatInt(int64(i), 10) + ")" + } + return _Type_name[_Type_index[i]:_Type_index[i+1]] +} diff --git a/auth/value.go b/auth/value.go index bb9980e5..c04772b7 100644 --- a/auth/value.go +++ b/auth/value.go @@ -31,7 +31,7 @@ func NewValueAuth(token string, cookie string) (ValueAuth, error) { if len(cookie) == 0 { return ValueAuth{}, ErrNoCookies } - c.Cookie = []http.Cookie{ + c.Cookie = []*http.Cookie{ makeCookie("d", cookie), makeCookie("d-s", fmt.Sprintf("%d", time.Now().Unix()-10)), } @@ -45,11 +45,11 @@ func (ValueAuth) Type() Type { var timeFunc = time.Now -func makeCookie(key, val string) http.Cookie { +func makeCookie(key, val string) *http.Cookie { if !urlsafe(val) { val = url.QueryEscape(val) } - return http.Cookie{ + return &http.Cookie{ Name: key, Value: val, Path: defaultPath, diff --git a/auth/value_test.go b/auth/value_test.go index 07e0fadc..94ab73e4 100644 --- a/auth/value_test.go +++ b/auth/value_test.go @@ -25,12 +25,12 @@ func Test_makeCookie(t *testing.T) { tests := []struct { name string args args - want http.Cookie + want *http.Cookie }{ { "values are properly propagated", args{"key", "xoxd-412451%2Babcdef"}, - http.Cookie{ + &http.Cookie{ Name: "key", Value: "xoxd-412451%2Babcdef", Path: defaultPath, @@ -42,7 +42,7 @@ func Test_makeCookie(t *testing.T) { { "URL Unsafe values are escaped", args{"key", "xoxd-412451+abcdef"}, - http.Cookie{ + &http.Cookie{ Name: "key", Value: "xoxd-412451%2Babcdef", Path: defaultPath, diff --git a/go.mod b/go.mod index 19172e33..c4d356ea 100644 --- a/go.mod +++ b/go.mod @@ -10,14 +10,14 @@ require ( github.com/golang/mock v1.6.0 github.com/joho/godotenv v1.4.0 github.com/playwright-community/playwright-go v0.2000.1 + github.com/rusq/chttp v1.0.1 github.com/rusq/dlog v1.3.3 github.com/rusq/osenv/v2 v2.0.1 github.com/rusq/secure v0.0.4 github.com/rusq/tracer v1.0.1 github.com/schollz/progressbar/v3 v3.8.6 - github.com/slack-go/slack v0.11.0 + github.com/slack-go/slack v0.12.1 github.com/stretchr/testify v1.7.1 - golang.org/x/net v0.7.0 golang.org/x/time v0.0.0-20220922220347-f3bd1da661af ) @@ -35,11 +35,10 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.3.1 // indirect golang.org/x/crypto v0.0.0-20221012134737-56aed061732a // indirect + golang.org/x/net v0.7.0 // indirect golang.org/x/sys v0.5.0 // indirect golang.org/x/term v0.5.0 // indirect golang.org/x/text v0.7.0 // indirect gopkg.in/square/go-jose.v2 v2.6.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) - -replace github.com/slack-go/slack => github.com/rusq/slack v0.12.0 diff --git a/go.sum b/go.sum index 297e1fe6..65ed4c0e 100644 --- a/go.sum +++ b/go.sum @@ -57,18 +57,22 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.3.1 h1:SDPP7SHNl1L7KrEFCSJslJ/DM9DT02Nq2C61XrfHMmk= github.com/rivo/uniseg v0.3.1/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rusq/chttp v1.0.1 h1:j3WE7+jQE9Rgw0E6mGMje8HxMCv09QRkKvR0oZ1R2vY= +github.com/rusq/chttp v1.0.1/go.mod h1:9H/mMp/iUc4xDkSOY0rL6ecxd/YyaW7zE9GhR+mZfRg= github.com/rusq/dlog v1.3.3 h1:Q9fZW1H/YEnlDg3Ph1k/BRSBfi/q5ezI+8Metws9tTI= github.com/rusq/dlog v1.3.3/go.mod h1:kjZAEvBu7m3+mnJQKoIeLul1YB3kJq/6lZBdDTZmpzA= github.com/rusq/osenv/v2 v2.0.1 h1:1LtNt8VNV/W86wb38Hyu5W3Rwqt/F1JNRGE+8GRu09o= github.com/rusq/osenv/v2 v2.0.1/go.mod h1:+wJBSisjNZpfoD961JzqjaM+PtaqSusO3b4oVJi7TFY= github.com/rusq/secure v0.0.4 h1:svpiZHfHnx89eEDCCFI9OXG1Y8hL9kUWUG6fJbrWUOI= github.com/rusq/secure v0.0.4/go.mod h1:F1QilMKreuFRjov0UY7DZSIXn77/8RqMVGu2zV0RtqY= -github.com/rusq/slack v0.12.0 h1:/LTTLPAoZU7Epvd11i+/OHJfHTac33jfnlqiH7jlVuM= -github.com/rusq/slack v0.12.0/go.mod h1:hlGi5oXA+Gt+yWTPP0plCdRKmjsDxecdHxYQdlMQKOw= github.com/rusq/tracer v1.0.1 h1:5u4PCV8NGO97VuAINQA4gOVRkPoqHimLE2jpezRVNMU= github.com/rusq/tracer v1.0.1/go.mod h1:Rqu48C3/K8bA5NPmF20Hft73v431MQIdM+Co+113pME= github.com/schollz/progressbar/v3 v3.8.6 h1:QruMUdzZ1TbEP++S1m73OqRJk20ON11m6Wqv4EoGg8c= github.com/schollz/progressbar/v3 v3.8.6/go.mod h1:W5IEwbJecncFGBvuEh4A7HT1nZZ6WNIL2i3qbnI0WKY= +github.com/slack-go/slack v0.11.0 h1:sBBjQz8LY++6eeWhGJNZpRm5jvLRNnWBFZ/cAq58a6k= +github.com/slack-go/slack v0.11.0/go.mod h1:hlGi5oXA+Gt+yWTPP0plCdRKmjsDxecdHxYQdlMQKOw= +github.com/slack-go/slack v0.12.1 h1:X97b9g2hnITDtNsNe5GkGx6O2/Sz/uC20ejRZN6QxOw= +github.com/slack-go/slack v0.12.1/go.mod h1:hlGi5oXA+Gt+yWTPP0plCdRKmjsDxecdHxYQdlMQKOw= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= diff --git a/internal/chttp/chttp.go b/internal/chttp/chttp.go deleted file mode 100644 index c917512f..00000000 --- a/internal/chttp/chttp.go +++ /dev/null @@ -1,51 +0,0 @@ -// Package chttp provides some convenience function to wrap the standard http -// Client. -package chttp - -import ( - "net/http" - "net/http/cookiejar" - "net/url" - - "golang.org/x/net/publicsuffix" -) - -// New inits the HTTP client with cookies. -func New(cookieDomain string, cookies []*http.Cookie, rt http.RoundTripper) *http.Client { - jar, _ := cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List}) - url, err := url.Parse(cookieDomain) - if err != nil { - panic(err) //shouldn't happen - } - jar.SetCookies(url, cookies) - cl := http.Client{ - Jar: jar, - Transport: rt, - } - return &cl -} - -// NewWithToken returns the HTTP client with cookies, that augments requests -// with slack token. -func NewWithToken(token string, cookieDomain string, cookies []*http.Cookie) *http.Client { - tr := NewTransport(nil) - tr.BeforeReq = func(req *http.Request) { - // req.V - // if req.Method == http.MethodGet { - // req.Form.Add("token", token) - // } - } - return New(cookieDomain, cookies, tr) -} - -func sliceOfPtr[T any](cc []T) []*T { - var ret = make([]*T, len(cc)) - for i := range cc { - ret[i] = &cc[i] - } - return ret -} - -func ConvertCookies(cc []http.Cookie) []*http.Cookie { - return sliceOfPtr(cc) -} diff --git a/internal/chttp/transport.go b/internal/chttp/transport.go deleted file mode 100644 index 5cab9b5b..00000000 --- a/internal/chttp/transport.go +++ /dev/null @@ -1,33 +0,0 @@ -package chttp - -import "net/http" - -// a simple wrapper for http.RoundTripper to do something before and after RoundTrip -type Transport struct { - tr http.RoundTripper - BeforeReq func(req *http.Request) - AfterReq func(resp *http.Response, req *http.Request) -} - -func NewTransport(tr http.RoundTripper) *Transport { - t := &Transport{} - if tr == nil { - tr = http.DefaultTransport - } - t.tr = tr - return t -} - -func (t *Transport) RoundTrip(req *http.Request) (resp *http.Response, err error) { - if t.BeforeReq != nil { - t.BeforeReq(req) - } - resp, err = t.tr.RoundTrip(req) - if err != nil { - return - } - if t.AfterReq != nil { - t.AfterReq(resp, req) - } - return -} diff --git a/internal/edge/edge.go b/internal/edge/edge.go index c393a0ca..da8ed143 100644 --- a/internal/edge/edge.go +++ b/internal/edge/edge.go @@ -9,8 +9,8 @@ import ( "fmt" "net/http" + "github.com/rusq/chttp" "github.com/rusq/slackdump/v2/auth" - "github.com/rusq/slackdump/v2/internal/chttp" ) type Client struct { @@ -19,15 +19,20 @@ type Client struct { token string } -func New(teamID string, token string, cookies []*http.Cookie) *Client { +func New(teamID string, token string, cookies []*http.Cookie) (*Client, error) { + cl, err := chttp.New("https://slack.com", cookies) + if err != nil { + return nil, err + } return &Client{ - cl: chttp.NewWithToken(token, "https://slack.com", cookies), + cl: cl, token: token, - apiPath: fmt.Sprintf("https://edgeapi.slack.com/cache/%s/", teamID)} + apiPath: fmt.Sprintf("https://edgeapi.slack.com/cache/%s/", teamID), + }, nil } -func NewWithProvider(teamID string, prov auth.Provider) *Client { - return New(teamID, prov.SlackToken(), chttp.ConvertCookies(prov.Cookies())) +func NewWithProvider(teamID string, prov auth.Provider) (*Client, error) { + return New(teamID, prov.SlackToken(), prov.Cookies()) } func (cl *Client) Raw() *http.Client { diff --git a/internal/edge/edge_test.go b/internal/edge/edge_test.go index 20eb345e..2c18363d 100644 --- a/internal/edge/edge_test.go +++ b/internal/edge/edge_test.go @@ -10,7 +10,6 @@ import ( "github.com/joho/godotenv" "github.com/rusq/slackdump/v2/auth" - "github.com/rusq/slackdump/v2/internal/chttp" ) var _ = godotenv.Load() @@ -26,11 +25,14 @@ func TestNew(t *testing.T) { t.Skip("test token not set") } - au, err := auth.NewValueAuth(testToken, testCookie) + prov, err := auth.NewValueAuth(testToken, testCookie) + if err != nil { + t.Fatal(err) + } + cl, err := New(testTeam, prov.SlackToken(), prov.Cookies()) if err != nil { t.Fatal(err) } - cl := New(testTeam, au.SlackToken(), chttp.ConvertCookies(au.Cookies())) req := UsersListRequest{ Channels: []string{"C6NL0QQSG"}, Filter: "everyone AND NOT bots AND NOT apps", @@ -55,7 +57,10 @@ func TestGetUsers(t *testing.T) { if err != nil { t.Fatal(err) } - cl := NewWithProvider(testTeam, au) + cl, err := NewWithProvider(testTeam, au) + if err != nil { + t.Fatal(err) + } ui, err := cl.GetUsers(context.Background(), []string{"U0LKLSNER", "U03K9GLS2", "U03KMNRQS"}) if err != nil { t.Fatal(err) diff --git a/slackdump.go b/slackdump.go index 3a00c816..6dffe746 100644 --- a/slackdump.go +++ b/slackdump.go @@ -14,6 +14,7 @@ import ( "github.com/slack-go/slack" "golang.org/x/time/rate" + "github.com/rusq/chttp" "github.com/rusq/slackdump/v2/auth" "github.com/rusq/slackdump/v2/fsadapter" "github.com/rusq/slackdump/v2/internal/network" @@ -85,7 +86,12 @@ func NewWithOptions(ctx context.Context, authProvider auth.Provider, opts Option return nil, err } - cl := slack.New(authProvider.SlackToken(), slack.OptionCookieRAW(toPtrCookies(authProvider.Cookies())...)) + httpCl, err := chttp.New("https://slack.com", authProvider.Cookies()) + if err != nil { + return nil, err + } + + cl := slack.New(authProvider.SlackToken(), slack.OptionHTTPClient(httpCl)) authTestResp, err := cl.AuthTestContext(ctx) if err != nil { @@ -123,12 +129,16 @@ func TestAuth(ctx context.Context, provider auth.Provider) error { ctx, task := trace.NewTask(ctx, "TestAuth") defer task.End() - cl := slack.New(provider.SlackToken(), slack.OptionCookieRAW(toPtrCookies(provider.Cookies())...)) + httpCl, err := chttp.New("https://slack.com", provider.Cookies()) + if err != nil { + return err + } + + cl := slack.New(provider.SlackToken(), slack.OptionHTTPClient(httpCl)) region := trace.StartRegion(ctx, "AuthTestContext") defer region.End() - _, err := cl.AuthTestContext(ctx) - if err != nil { + if _, err := cl.AuthTestContext(ctx); err != nil { return &AuthError{Err: err} } return nil diff --git a/tools/rawoutput/main.go b/tools/rawoutput/main.go index 429f52d6..bc943e1e 100644 --- a/tools/rawoutput/main.go +++ b/tools/rawoutput/main.go @@ -12,8 +12,8 @@ import ( "net/url" "os" + "github.com/rusq/chttp" "github.com/rusq/slackdump/v2/internal/app" - "github.com/rusq/slackdump/v2/internal/chttp" "github.com/rusq/slackdump/v2/internal/structures" ) @@ -72,7 +72,10 @@ func run(ctx context.Context, p params) error { if err != nil { return err } - cl := chttp.New(domain, chttp.ConvertCookies(prov.Cookies()), chttp.NewTransport(nil)) + cl, err := chttp.New(domain, prov.Cookies()) + if err != nil { + return err + } if err := saveOutput(ctx, cl, p.output, prov.SlackToken(), sl); err != nil { return err }