diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..5cb2428 --- /dev/null +++ b/.drone.yml @@ -0,0 +1,14 @@ +--- +kind: pipeline +type: kubernetes +name: default + +steps: +- name: backend + image: golang:1.18 + commands: + - go install honnef.co/go/tools/cmd/staticcheck@latest + - go build ./... + - go vet ./... + - staticcheck ./... + - go test -bench=. -benchmem -cover ./... diff --git a/config/config.go b/config/config.go index b789880..643187f 100644 --- a/config/config.go +++ b/config/config.go @@ -5,14 +5,14 @@ import ( "os" ) -type TwitterCredentials struct { - ClientID, ClientSecret, CallbackURL string +type TwitterConfig struct { + ClientID, ClientSecret, CallbackURL, AuthorizeURL, TokenURL string } type Config struct { PublicPath string SessionKey string ListenAddr string - Twitter TwitterCredentials + Twitter TwitterConfig } func NewFromEnv() (Config, error) { @@ -28,10 +28,12 @@ func NewFromEnv() (Config, error) { PublicPath: os.Getenv("ELON_PUBLIC_PATH"), SessionKey: sessionKey, ListenAddr: listenAddr, - Twitter: TwitterCredentials{ + Twitter: TwitterConfig{ ClientID: os.Getenv("ELON_TWITTER_CLIENT_ID"), ClientSecret: os.Getenv("ELON_TWITTER_CLIENT_SECRET"), CallbackURL: os.Getenv("ELON_TWITTER_CALLBACK_URL"), + AuthorizeURL: os.Getenv("ELON_TWITTER_AUTHORIZE_URL"), + TokenURL: os.Getenv("ELON_TWITTER_TOKEN_URL"), }, }, nil } diff --git a/generated/mocks/Store.go b/generated/mocks/Store.go new file mode 100644 index 0000000..63ed7a2 --- /dev/null +++ b/generated/mocks/Store.go @@ -0,0 +1,87 @@ +// Code generated by mockery v2.12.2. DO NOT EDIT. + +package mocks + +import ( + http "net/http" + + sessions "github.com/gorilla/sessions" + mock "github.com/stretchr/testify/mock" + + testing "testing" +) + +// Store is an autogenerated mock type for the Store type +type Store struct { + mock.Mock +} + +// Get provides a mock function with given fields: r, name +func (_m *Store) Get(r *http.Request, name string) (*sessions.Session, error) { + ret := _m.Called(r, name) + + var r0 *sessions.Session + if rf, ok := ret.Get(0).(func(*http.Request, string) *sessions.Session); ok { + r0 = rf(r, name) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*sessions.Session) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(*http.Request, string) error); ok { + r1 = rf(r, name) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// New provides a mock function with given fields: r, name +func (_m *Store) New(r *http.Request, name string) (*sessions.Session, error) { + ret := _m.Called(r, name) + + var r0 *sessions.Session + if rf, ok := ret.Get(0).(func(*http.Request, string) *sessions.Session); ok { + r0 = rf(r, name) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*sessions.Session) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(*http.Request, string) error); ok { + r1 = rf(r, name) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Save provides a mock function with given fields: r, w, s +func (_m *Store) Save(r *http.Request, w http.ResponseWriter, s *sessions.Session) error { + ret := _m.Called(r, w, s) + + var r0 error + if rf, ok := ret.Get(0).(func(*http.Request, http.ResponseWriter, *sessions.Session) error); ok { + r0 = rf(r, w, s) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// NewStore creates a new instance of Store. It also registers the testing.TB interface on the mock and a cleanup function to assert the mocks expectations. +func NewStore(t testing.TB) *Store { + mock := &Store{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/go.mod b/go.mod index 6cf77a2..a8145bb 100644 --- a/go.mod +++ b/go.mod @@ -4,15 +4,23 @@ go 1.18 require ( github.com/go-chi/chi v1.5.4 + github.com/gorilla/sessions v1.2.1 + github.com/stretchr/testify v1.7.1 + go.uber.org/zap v1.21.0 golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5 ) require ( + github.com/davecgh/go-spew v1.1.1 // indirect github.com/golang/protobuf v1.4.2 // indirect github.com/google/go-cmp v0.5.7 // indirect github.com/gorilla/securecookie v1.1.1 // indirect - github.com/gorilla/sessions v1.2.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/objx v0.1.0 // indirect + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/multierr v1.8.0 // indirect golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd // indirect google.golang.org/appengine v1.6.6 // indirect google.golang.org/protobuf v1.25.0 // indirect + gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect ) diff --git a/go.sum b/go.sum index aee8733..8f507ee 100644 --- a/go.sum +++ b/go.sum @@ -33,6 +33,8 @@ cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9 dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= +github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= @@ -40,6 +42,8 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMn github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= @@ -107,22 +111,43 @@ github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1: github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI= +go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/multierr v1.8.0 h1:dg6GjLku4EH+249NNmoIciG9N/jURbDG+pFlTkhzIC8= +go.uber.org/multierr v1.8.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak= +go.uber.org/zap v1.21.0 h1:WefMeulhovoZ2sYXz7st6K0sLj7bBhpiFaud4r4zST8= +go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= @@ -158,6 +183,7 @@ golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzB golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -184,6 +210,7 @@ golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/ golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd h1:O7DYs+zxREGLKzKoMQrtrEacpb0ZVXA5rIwylE2Xchk= golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -201,6 +228,7 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -226,8 +254,12 @@ golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -278,6 +310,7 @@ golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roY golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -359,9 +392,15 @@ google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGj google.golang.org/protobuf v1.25.0 h1:Ejskq+SyPohKW+1uil0JJMtmHCgJPJ/qWTxr8qp+R4c= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/httpserver/handler.go b/httpserver/handler.go new file mode 100644 index 0000000..490d1cb --- /dev/null +++ b/httpserver/handler.go @@ -0,0 +1,155 @@ +package httpserver + +//go:generate mockery --recursive --srcpkg github.com/gorilla/sessions --name Store --output ../generated/mocks + +import ( + "context" + "html/template" + "io" + "net/http" + + "git.netflux.io/rob/elon-eats-my-tweets/config" + "github.com/go-chi/chi" + "github.com/go-chi/chi/middleware" + "github.com/gorilla/sessions" + "go.uber.org/zap" + "golang.org/x/oauth2" +) + +const ( + sessionName = "elon_session" + sessionKeyState = "state" + sessionKeyPkceVerifier = "pkce_verifier" + stateLen = 64 + pkceVerifierLen = 64 +) + +type handler struct { + templates *template.Template + oauth2Config *oauth2.Config + sessionStore sessions.Store + tokenGenerator TokenGenerator + logger *zap.SugaredLogger +} + +func NewHandler(cfg config.Config, templates *template.Template, sessionStore sessions.Store, tokenGenerator TokenGenerator, logger *zap.Logger) http.Handler { + r := chi.NewRouter() + r.Use(middleware.RequestID) + r.Use(middleware.RealIP) + r.Use(loggerMiddleware(logger)) + r.Use(middleware.Recoverer) + + h := handler{ + templates: templates, + oauth2Config: &oauth2.Config{ + ClientID: cfg.Twitter.ClientID, + ClientSecret: cfg.Twitter.ClientSecret, + RedirectURL: cfg.Twitter.CallbackURL, + Scopes: []string{"tweet.read", "tweet.write", "users.read", "offline.access"}, + Endpoint: oauth2.Endpoint{ + AuthURL: cfg.Twitter.AuthorizeURL, + TokenURL: cfg.Twitter.TokenURL, + AuthStyle: oauth2.AuthStyleInHeader, + }, + }, + sessionStore: sessionStore, + tokenGenerator: tokenGenerator, + logger: logger.Sugar(), + } + + r.Get("/", h.getIndex) + r.Get("/login", h.getLogin) + r.Get("/callback", h.getCallback) + + return r +} + +func (h *handler) getIndex(w http.ResponseWriter, r *http.Request) { + if err := h.templates.ExecuteTemplate(w, "index", nil); err != nil { + h.logger.With("err", err).Error("error rendering template") + http.Error(w, "error rendering template", http.StatusInternalServerError) + return + } +} + +func (h *handler) getLogin(w http.ResponseWriter, r *http.Request) { + state := h.tokenGenerator.GenerateToken(stateLen) + pkceVerifier := h.tokenGenerator.GenerateToken(pkceVerifierLen) + + session, _ := h.sessionStore.Get(r, sessionName) + session.Values[sessionKeyState] = state + session.Values[sessionKeyPkceVerifier] = pkceVerifier + if err := session.Save(r, w); err != nil { + h.logger.With("err", err).Error("error saving session") + http.Error(w, "unexpected error", http.StatusInternalServerError) + return + } + + url := h.oauth2Config.AuthCodeURL( + state, + oauth2.SetAuthURLParam("code_challenge", encodeSHA256(pkceVerifier)), + oauth2.SetAuthURLParam("code_challenge_method", "S256"), + ) + + http.Redirect(w, r, url, http.StatusFound) +} + +func (h *handler) getCallback(w http.ResponseWriter, r *http.Request) { + session, err := h.sessionStore.Get(r, sessionName) + if err != nil { + h.logger.With("err", err).Error("error reading session") + http.Error(w, "error reading session", http.StatusBadRequest) + return + } + + state, ok := session.Values[sessionKeyState] + if !ok || state == "" { + h.logger.Error("empty state parameter in oauth2 request") + http.Error(w, "error validating request", http.StatusBadRequest) + return + } + + if state != r.URL.Query().Get("state") { + h.logger.Error("unexpected state in oauth2 request") + http.Error(w, "error validating request", http.StatusBadRequest) + return + } + + code := r.URL.Query().Get("code") + if code == "" { + h.logger.Error("empty code in oauth2 request") + http.Error(w, "invalid code", http.StatusBadRequest) + return + } + + pkceVerifier, ok := session.Values[sessionKeyPkceVerifier] + if !ok || pkceVerifier == "" { + h.logger.Error("no pkce verifier found in session") + http.Error(w, "error reading session", http.StatusBadRequest) + return + } + + token, err := h.oauth2Config.Exchange(context.Background(), code, oauth2.SetAuthURLParam("code_verifier", pkceVerifier.(string))) + if err != nil { + h.logger.With("err", err).Error("error exchanging code for access token") + http.Error(w, "error exchanging code", http.StatusForbidden) + return + } + + client := h.oauth2Config.Client(context.Background(), token) + resp, err := client.Get("https://api.twitter.com/2/users/me") + if err != nil { + h.logger.With("err", err).Error("error fetching user") + http.Error(w, "error fetching user", http.StatusInternalServerError) + return + } + defer resp.Body.Close() + // TODO: do something sensible + body, _ := io.ReadAll(resp.Body) + + w.Header().Set("content-type", "application/json") + w.WriteHeader(http.StatusOK) + if _, err = w.Write([]byte(body)); err != nil { + h.logger.With("err", err).Error("error writing response") + } +} diff --git a/httpserver/handler_test.go b/httpserver/handler_test.go new file mode 100644 index 0000000..a8a3905 --- /dev/null +++ b/httpserver/handler_test.go @@ -0,0 +1,252 @@ +package httpserver_test + +import ( + "errors" + "html/template" + "io/ioutil" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "git.netflux.io/rob/elon-eats-my-tweets/config" + "git.netflux.io/rob/elon-eats-my-tweets/generated/mocks" + "git.netflux.io/rob/elon-eats-my-tweets/httpserver" + "github.com/gorilla/sessions" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +var templates = template.Must(template.ParseGlob(filepath.Join("..", "public", "views", "*.html"))) + +// mockTokenGenerator implements httpserver.TokenGenerator. +type mockTokenGenerator struct { + i int + tokens []string +} + +func (g *mockTokenGenerator) GenerateToken(_ int) string { + i := g.i + if len(g.tokens) <= i { + return "" + } + g.i++ + return g.tokens[i] +} + +func TestGetIndex(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + + handler := httpserver.NewHandler( + config.Config{}, + templates, + &mocks.Store{}, + &mockTokenGenerator{}, + zap.NewNop(), + ) + + handler.ServeHTTP(rec, req) + res := rec.Result() + defer res.Body.Close() + body, err := ioutil.ReadAll(res.Body) + require.NoError(t, err) + + assert.Equal(t, http.StatusOK, res.StatusCode) + assert.Contains(t, string(body), "Sign in with Twitter") +} + +func TestLogin(t *testing.T) { + testCases := []struct { + name string + sessionSaveError error + wantStatusCode int + wantLocation string + wantRespBody string + }{ + { + name: "successful login", + wantStatusCode: http.StatusFound, + wantLocation: "https://www.example.com/oauth/authorize?client_id=foo&code_challenge=RdoE4fOeAO8YelxeEEd70qNDoVgzl4844utzxsozlR4&code_challenge_method=S256&redirect_uri=https%3A%2F%2Fwww.example.com%2Fcallback&response_type=code&scope=tweet.read+tweet.write+users.read+offline.access&state=state", + }, + { + name: "error saving session", + wantStatusCode: http.StatusInternalServerError, + sessionSaveError: errors.New("boom"), + wantRespBody: "unexpected error", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var mockStore mocks.Store + sess := sessions.NewSession(&mockStore, "elon_session") + mockStore.On("Get", mock.Anything, "elon_session").Return(sess, nil) + mockStore.On("Save", mock.Anything, mock.Anything, sess).Return(tc.sessionSaveError) + + handler := httpserver.NewHandler( + config.Config{ + Twitter: config.TwitterConfig{ + ClientID: "foo", + ClientSecret: "bar", + CallbackURL: "https://www.example.com/callback", + AuthorizeURL: "https://www.example.com/oauth/authorize", + TokenURL: "https://www.example.com/oauth/token", + }, + }, + templates, + &mockStore, + &mockTokenGenerator{tokens: []string{"state", "pkceVerifier"}}, + zap.NewNop(), + ) + + req := httptest.NewRequest(http.MethodGet, "/login", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + res := rec.Result() + defer res.Body.Close() + + assert.Equal(t, tc.wantStatusCode, res.StatusCode) + if tc.wantRespBody == "" { + assert.Equal(t, tc.wantLocation, res.Header.Get("Location")) + } + + if tc.wantRespBody != "" { + body, err := ioutil.ReadAll(res.Body) + require.NoError(t, err) + assert.Contains(t, string(body), tc.wantRespBody) + } + }) + } +} + +func TestCallback(t *testing.T) { + testCases := []struct { + name string + state string + sessionState string + sessionPkceVerifier string + code string + sessionReadError error + oauth2StatusCode int + wantStatusCode int + wantError string + }{ + { + name: "unable to read session", + sessionReadError: errors.New("boom"), + wantStatusCode: http.StatusBadRequest, + wantError: "error reading session", + }, + { + name: "missing state", + state: "mystate", + sessionState: "", + wantStatusCode: http.StatusBadRequest, + wantError: "error validating request", + }, + { + name: "unexpected state", + state: "mystate", + sessionState: "foostate", + wantStatusCode: http.StatusBadRequest, + wantError: "error validating request", + }, + { + name: "empty code", + state: "mystate", + sessionState: "mystate", + code: "", + wantStatusCode: http.StatusBadRequest, + wantError: "invalid code", + }, + { + name: "empty pkce verifier", + state: "mystate", + sessionState: "mystate", + code: "mycode", + sessionPkceVerifier: "", + wantStatusCode: http.StatusBadRequest, + wantError: "error reading session", + }, + { + name: "error exchanging code", + state: "mystate", + sessionState: "mystate", + code: "mycode", + sessionPkceVerifier: "mypkceverifier", + oauth2StatusCode: http.StatusInternalServerError, + wantStatusCode: http.StatusForbidden, + wantError: "error exchanging code", + }, + { + name: "successful exchange", + state: "mystate", + sessionState: "mystate", + code: "mycode", + sessionPkceVerifier: "mypkceverifier", + oauth2StatusCode: http.StatusOK, + wantStatusCode: http.StatusOK, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var mockStore mocks.Store + sess := sessions.NewSession(&mockStore, "elon_session") + sess.Values["state"] = tc.sessionState + sess.Values["pkce_verifier"] = tc.sessionPkceVerifier + mockStore.On("Get", mock.Anything, "elon_session").Return(sess, tc.sessionReadError) + + const callbackURL = "https://www.example.com/callback" + oauthHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/oauth/token", r.URL.Path) + + require.NoError(t, r.ParseForm()) + assert.Equal(t, tc.code, r.PostFormValue("code")) + assert.Equal(t, tc.sessionPkceVerifier, r.PostFormValue("code_verifier")) + assert.Equal(t, "authorization_code", r.PostFormValue("grant_type")) + assert.Equal(t, callbackURL, r.PostFormValue("redirect_uri")) + + w.WriteHeader(tc.oauth2StatusCode) + if tc.oauth2StatusCode == http.StatusOK { + w.Write([]byte(`access_token=foo&expires_in=3600&refresh_token=&token_type=bearer"}`)) + } + }) + srv := httptest.NewServer(oauthHandler) + + handler := httpserver.NewHandler( + config.Config{ + Twitter: config.TwitterConfig{ + ClientID: "foo", + ClientSecret: "bar", + CallbackURL: callbackURL, + AuthorizeURL: srv.URL + "/oauth/authorize", + TokenURL: srv.URL + "/oauth/token", + }, + }, + templates, + &mockStore, + nil, + zap.NewNop(), + ) + + req := httptest.NewRequest(http.MethodGet, "/callback?state="+tc.state+"&code="+tc.code, nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + res := rec.Result() + defer res.Body.Close() + + assert.Equal(t, tc.wantStatusCode, res.StatusCode) + + if tc.wantError != "" { + body, err := ioutil.ReadAll(res.Body) + require.NoError(t, err) + assert.Contains(t, string(body), tc.wantError) + } + }) + } +} diff --git a/httpserver/httpserver.go b/httpserver/httpserver.go index 6ca2d5f..b482627 100644 --- a/httpserver/httpserver.go +++ b/httpserver/httpserver.go @@ -1,176 +1 @@ package httpserver - -import ( - "context" - "crypto/sha256" - "encoding/base64" - "fmt" - "io" - "log" - "math/rand" - "net/http" - "path/filepath" - "text/template" - - "git.netflux.io/rob/elon-eats-my-tweets/config" - "github.com/go-chi/chi" - "github.com/go-chi/chi/middleware" - "github.com/gorilla/sessions" - "golang.org/x/oauth2" -) - -const ( - sessionName = "elon_session" - sessionKeyState = "state" - sessionKeyPkceVerifier = "pkce_verifier" - stateLen = 64 - pkceVerifierLen = 64 -) - -type handler struct { - templates *template.Template - oauth2Config *oauth2.Config - sessionStore *sessions.CookieStore -} - -func newHandler(cfg config.Config, templates *template.Template) *handler { - return &handler{ - templates: templates, - sessionStore: sessions.NewCookieStore([]byte(cfg.SessionKey)), - oauth2Config: &oauth2.Config{ - ClientID: cfg.Twitter.ClientID, - ClientSecret: cfg.Twitter.ClientSecret, - RedirectURL: cfg.Twitter.CallbackURL, - Scopes: []string{"tweet.read", "tweet.write", "users.read", "offline.access"}, - Endpoint: oauth2.Endpoint{ - AuthURL: "https://twitter.com/i/oauth2/authorize", - TokenURL: "https://api.twitter.com/2/oauth2/token", - AuthStyle: oauth2.AuthStyleInHeader, - }, - }, - } -} - -func (h *handler) getIndex(w http.ResponseWriter, r *http.Request) { - if err := h.templates.ExecuteTemplate(w, "index", nil); err != nil { - log.Printf("error rendering template: %v", err) - http.Error(w, "error rendering template", http.StatusInternalServerError) - return - } -} - -func (h *handler) getLogin(w http.ResponseWriter, r *http.Request) { - state := randSeq(stateLen) - pkceVerifier := randSeq(pkceVerifierLen) - - session, _ := h.sessionStore.Get(r, sessionName) - session.Values[sessionKeyState] = state - session.Values[sessionKeyPkceVerifier] = pkceVerifier - if err := session.Save(r, w); err != nil { - log.Printf("error saving session: %v", err) - http.Error(w, "error saving session", http.StatusBadRequest) - return - } - - url := h.oauth2Config.AuthCodeURL( - state, - oauth2.SetAuthURLParam("code_challenge", encodeSHA256(pkceVerifier)), - oauth2.SetAuthURLParam("code_challenge_method", "S256"), - ) - - http.Redirect(w, r, url, http.StatusTemporaryRedirect) -} - -func (h *handler) getCallback(w http.ResponseWriter, r *http.Request) { - session, err := h.sessionStore.Get(r, sessionName) - if err != nil { - log.Printf("error reading session: %v", err) - http.Error(w, "error reading session", http.StatusBadRequest) - return - } - - state, ok := session.Values[sessionKeyState] - if !ok { - log.Println("empty state", err) - http.Error(w, "error reading session", http.StatusBadRequest) - return - } - - if state != r.URL.Query().Get("state") { - http.Error(w, "error validating request", http.StatusBadRequest) - return - } - - code := r.URL.Query().Get("code") - if code == "" { - http.Error(w, "empty code", http.StatusBadRequest) - return - } - - pkceVerifier, ok := session.Values[sessionKeyPkceVerifier] - if !ok { - log.Println("empty code challenge", err) - http.Error(w, "error reading session", http.StatusBadRequest) - return - } - - token, err := h.oauth2Config.Exchange(context.Background(), code, oauth2.SetAuthURLParam("code_verifier", pkceVerifier.(string))) - if err != nil { - log.Printf("error exchanging code: %v", err) - http.Error(w, "error exchanging code", http.StatusForbidden) - return - } - - client := h.oauth2Config.Client(context.Background(), token) - resp, err := client.Get("https://api.twitter.com/2/users/me") - if err != nil { - log.Printf("error fetching users/me: %v", err) - http.Error(w, "error fetching user", http.StatusInternalServerError) - return - } - defer resp.Body.Close() - // TODO: do something sensible - body, _ := io.ReadAll(resp.Body) - - w.Header().Set("content-type", "application/json") - w.WriteHeader(http.StatusOK) - if _, err = w.Write([]byte(body)); err != nil { - log.Printf("error writing response: %v", err) - } -} - -func Start(cfg config.Config) error { - r := chi.NewRouter() - r.Use(middleware.RequestID) - r.Use(middleware.RealIP) - r.Use(middleware.Logger) - r.Use(middleware.Recoverer) - - templates, err := template.ParseGlob(filepath.Join(cfg.PublicPath, "views", "*.html")) - if err != nil { - return fmt.Errorf("error loading templates: %v", err) - } - - h := newHandler(cfg, templates) - r.Get("/", h.getIndex) - r.Get("/login", h.getLogin) - r.Get("/callback", h.getCallback) - - return http.ListenAndServe(":8000", r) -} - -// https://stackoverflow.com/questions/22892120/how-to-generate-a-random-string-of-a-fixed-length-in-go -var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") - -func randSeq(n int) string { - b := make([]rune, n) - for i := range b { - b[i] = letters[rand.Intn(len(letters))] - } - return string(b) -} - -func encodeSHA256(s string) string { - enc := sha256.Sum256([]byte(s)) - return base64.RawURLEncoding.EncodeToString(enc[:]) -} diff --git a/httpserver/logger.go b/httpserver/logger.go new file mode 100644 index 0000000..f6a52fe --- /dev/null +++ b/httpserver/logger.go @@ -0,0 +1,31 @@ +package httpserver + +import ( + "net/http" + "time" + + "github.com/go-chi/chi/middleware" + "go.uber.org/zap" +) + +func loggerMiddleware(l *zap.Logger) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + fn := func(w http.ResponseWriter, r *http.Request) { + ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) + + t1 := time.Now() + defer func() { + l.Info("HTTP", + zap.String("proto", r.Proto), + zap.String("path", r.URL.Path), + zap.Duration("dur", time.Since(t1)), + zap.Int("status", ww.Status()), + zap.Int("size", ww.BytesWritten()), + zap.String("reqId", middleware.GetReqID(r.Context()))) + }() + + next.ServeHTTP(ww, r) + } + return http.HandlerFunc(fn) + } +} diff --git a/httpserver/token.go b/httpserver/token.go new file mode 100644 index 0000000..b482627 --- /dev/null +++ b/httpserver/token.go @@ -0,0 +1 @@ +package httpserver diff --git a/httpserver/token_generator.go b/httpserver/token_generator.go new file mode 100644 index 0000000..3ac8262 --- /dev/null +++ b/httpserver/token_generator.go @@ -0,0 +1,33 @@ +package httpserver + +import ( + "crypto/sha256" + "encoding/base64" + "math/rand" + "time" +) + +func init() { + rand.Seed(time.Now().UnixNano()) +} + +type TokenGenerator interface { + GenerateToken(n int) string +} + +var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") + +type RandomTokenGenerator struct{} + +func (g RandomTokenGenerator) GenerateToken(n int) string { + b := make([]rune, n) + for i := range b { + b[i] = letters[rand.Intn(len(letters))] + } + return string(b) +} + +func encodeSHA256(s string) string { + enc := sha256.Sum256([]byte(s)) + return base64.RawURLEncoding.EncodeToString(enc[:]) +} diff --git a/main.go b/main.go index abccb02..57e6d1f 100644 --- a/main.go +++ b/main.go @@ -1,21 +1,39 @@ package main import ( + "html/template" "log" - "math/rand" - "time" + "net/http" + "path/filepath" "git.netflux.io/rob/elon-eats-my-tweets/config" "git.netflux.io/rob/elon-eats-my-tweets/httpserver" + "github.com/gorilla/sessions" + "go.uber.org/zap" ) func main() { - rand.Seed(time.Now().UnixNano()) - - c, err := config.NewFromEnv() + cfg, err := config.NewFromEnv() if err != nil { log.Fatal(err) } - log.Fatal(httpserver.Start(c)) + logger, err := zap.NewDevelopment() + if err != nil { + log.Fatal(err) + } + + templates, err := template.ParseGlob(filepath.Join(cfg.PublicPath, "views", "*.html")) + if err != nil { + log.Fatalf("error loading templates: %v", err) + } + + handler := httpserver.NewHandler( + cfg, + templates, + sessions.NewCookieStore([]byte(cfg.SessionKey)), + httpserver.RandomTokenGenerator{}, + logger, + ) + log.Fatal(http.ListenAndServe(cfg.ListenAddr, handler)) }