diff --git a/Gopkg.toml b/Gopkg.toml index b0d12af..c280986 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -1,29 +1,16 @@ -# Gopkg.toml example -# -# Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md -# for detailed Gopkg.toml documentation. -# -# required = ["github.com/user/thing/cmd/thing"] -# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] -# -# [[constraint]] -# name = "github.com/user/project" -# version = "1.0.0" -# -# [[constraint]] -# name = "github.com/user/project2" -# branch = "dev" -# source = "github.com/myfork/project2" -# -# [[override]] -# name = "github.com/x/y" -# version = "2.4.0" - [[constraint]] name = "github.com/mxpv/podcast" branch = "master" [[constraint]] name = "github.com/speps/go-hashids" - revision = "c6ced3330f133cec79e144fb11b2e4c5154059ae" \ No newline at end of file + revision = "c6ced3330f133cec79e144fb11b2e4c5154059ae" + +[[constraint]] + name = "github.com/gin-gonic/gin" + version = "1.2" + +[[constraint]] + name = "github.com/gin-contrib/sessions" + revision = "a71ea9167c616cf9f02bc928ed08bc390c8279bc" \ No newline at end of file diff --git a/cmd/app/main.go b/cmd/app/main.go index 39b8630..6661c1c 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -63,7 +63,7 @@ func main() { srv := http.Server{ Addr: fmt.Sprintf(":%d", 8080), - Handler: server.MakeHandlers(feed), + Handler: server.MakeHandlers(feed, cfg), } go func() { diff --git a/pkg/api/api.go b/pkg/api/api.go index e1001ea..fd0d85e 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -1,6 +1,8 @@ package api -import "time" +import ( + "time" +) type Provider string @@ -64,3 +66,11 @@ type CreateFeedRequest struct { Quality Quality `json:"quality" binding:"eq=high|eq=low"` Format Format `json:"format" binding:"eq=video|eq=audio"` } + +type Identity struct { + UserId string `json:"user_id"` + FullName string `json:"full_name"` + Email string `json:"email"` + ProfileURL string `json:"profile_url"` + FeatureLevel int `json:"feature_level"` +} diff --git a/pkg/config/config.go b/pkg/config/config.go index a836e10..da230a2 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -15,6 +15,7 @@ type AppConfig struct { PatreonSecret string `yaml:"patreonSecret"` PostgresConnectionURL string `yaml:"postgresConnectionUrl"` RedisURL string `yaml:"redisUrl"` + CookieSecret string `yaml:"cookieSecret"` } func ReadConfiguration() (cfg *AppConfig, err error) { @@ -35,6 +36,7 @@ func ReadConfiguration() (cfg *AppConfig, err error) { "patreonSecret": "PATREON_SECRET", "postgresConnectionUrl": "POSTGRES_CONNECTION_URL", "redisUrl": "REDIS_CONNECTION_URL", + "cookieSecret": "COOKIE_SECRET", } for k, v := range envmap { diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 7682ae7..ace4d47 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -15,6 +15,7 @@ vimeoApiKey: "2" patreonClientId: "3" patreonSecret: "4" postgresConnectionUrl: "5" +cookieSecret: "6" ` func TestReadYaml(t *testing.T) { @@ -32,6 +33,7 @@ func TestReadYaml(t *testing.T) { require.Equal(t, "3", cfg.PatreonClientId) require.Equal(t, "4", cfg.PatreonSecret) require.Equal(t, "5", cfg.PostgresConnectionURL) + require.Equal(t, "6", cfg.CookieSecret) } func TestReadEnv(t *testing.T) { @@ -43,6 +45,7 @@ func TestReadEnv(t *testing.T) { os.Setenv("PATREON_CLIENT_ID", "33") os.Setenv("PATREON_SECRET", "44") os.Setenv("POSTGRES_CONNECTION_URL", "55") + os.Setenv("COOKIE_SECRET", "66") cfg, err := ReadConfiguration() require.NoError(t, err) @@ -51,4 +54,6 @@ func TestReadEnv(t *testing.T) { require.Equal(t, "22", cfg.VimeoApiKey) require.Equal(t, "33", cfg.PatreonClientId) require.Equal(t, "44", cfg.PatreonSecret) + require.Equal(t, "55", cfg.PostgresConnectionURL) + require.Equal(t, "66", cfg.CookieSecret) } diff --git a/pkg/server/server.go b/pkg/server/server.go index b2be385..b650293 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -2,15 +2,27 @@ package server import ( "context" + "crypto/rand" + "encoding/base64" + "encoding/json" "go/build" "log" "net/http" "path" + "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" + "github.com/mxpv/patreon-go" itunes "github.com/mxpv/podcast" "github.com/mxpv/podsync/pkg/api" + "github.com/mxpv/podsync/pkg/config" "github.com/pkg/errors" + "golang.org/x/oauth2" +) + +const ( + campaignId = "278915" + identitySessionKey = "identity" ) type feed interface { @@ -19,12 +31,26 @@ type feed interface { GetMetadata(hashId string) (*api.Feed, error) } -func MakeHandlers(feed feed) http.Handler { +func MakeHandlers(feed feed, cfg *config.AppConfig) http.Handler { r := gin.New() r.Use(gin.Recovery()) + store := sessions.NewCookieStore([]byte(cfg.CookieSecret)) + r.Use(sessions.Sessions("podsync", store)) + // Static files + HTML + conf := &oauth2.Config{ + ClientID: cfg.PatreonClientId, + ClientSecret: cfg.PatreonSecret, + RedirectURL: "http://localhost:8080/patreon", + Scopes: []string{"users", "pledges-to-me", "my-campaign"}, + Endpoint: oauth2.Endpoint{ + AuthURL: patreon.AuthorizationURL, + TokenURL: patreon.AccessTokenURL, + }, + } + rootDir := path.Join(build.Default.GOPATH, "src/github.com/mxpv/podsync") log.Printf("Using root directory: %s", rootDir) @@ -32,10 +58,90 @@ func MakeHandlers(feed feed) http.Handler { r.LoadHTMLGlob(path.Join(rootDir, "templates/*.html")) r.GET("/", func(c *gin.Context) { - c.HTML(http.StatusOK, "index.html", gin.H{ - "identity": &struct{}{}, - "enableFeatures": true, - }) + s := sessions.Default(c) + + identity := &api.Identity{ + FeatureLevel: api.DefaultFeatures, + } + + buf, ok := s.Get(identitySessionKey).(string) + if ok { + // We are failed to deserialize Identity structure, do cleanup, force user to login again + if err := json.Unmarshal([]byte(buf), identity); err != nil { + s.Clear() + s.Save() + } + } + + c.HTML(http.StatusOK, "index.html", identity) + }) + + r.GET("/login", func(c *gin.Context) { + state := randToken() + + s := sessions.Default(c) + s.Set("state", state) + s.Save() + + authURL := conf.AuthCodeURL(state) + c.Redirect(http.StatusFound, authURL) + }) + + r.GET("/logout", func(c *gin.Context) { + s := sessions.Default(c) + s.Clear() + s.Save() + + c.Redirect(http.StatusFound, "/") + }) + + r.GET("/patreon", func(c *gin.Context) { + // Validate session state + s := sessions.Default(c) + state := s.Get("state") + if state != c.Query("state") { + c.String(http.StatusUnauthorized, "invalid state") + return + } + + // Exchange code with tokens + token, err := conf.Exchange(c.Request.Context(), c.Query("code")) + if err != nil { + c.String(http.StatusBadRequest, err.Error()) + return + } + + // Create Patreon client + tc := conf.Client(c.Request.Context(), token) + client := patreon.NewClient(tc) + + // Query user info from Patreon + user, err := client.FetchUser(patreon.WithIncludes(patreon.UserDefaultRelations)) + if err != nil { + c.String(http.StatusInternalServerError, err.Error()) + return + } + + identity := &api.Identity{ + UserId: user.Data.Id, + FullName: user.Data.Attributes.FullName, + Email: user.Data.Attributes.Email, + ProfileURL: user.Data.Attributes.URL, + FeatureLevel: api.ExtendedFeatures, + } + + // Serialize identity and return cookies + buf, err := json.Marshal(identity) + if err != nil { + c.String(http.StatusInternalServerError, err.Error()) + return + } + + s.Clear() + s.Set(identitySessionKey, string(buf)) + s.Save() + + c.Redirect(http.StatusFound, "/") }) // REST API @@ -103,3 +209,9 @@ func badRequest(err error) (int, interface{}) { func internalError(err error) (int, interface{}) { return http.StatusInternalServerError, gin.H{"error": err.Error()} } + +func randToken() string { + b := make([]byte, 32) + rand.Read(b) + return base64.StdEncoding.EncodeToString(b) +} diff --git a/pkg/server/server_test.go b/pkg/server/server_test.go index f8ea11f..1be635b 100644 --- a/pkg/server/server_test.go +++ b/pkg/server/server_test.go @@ -12,9 +12,12 @@ import ( "github.com/golang/mock/gomock" itunes "github.com/mxpv/podcast" "github.com/mxpv/podsync/pkg/api" + "github.com/mxpv/podsync/pkg/config" "github.com/stretchr/testify/require" ) +var cfg = &config.AppConfig{} + func TestCreateFeed(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -29,11 +32,11 @@ func TestCreateFeed(t *testing.T) { feed := NewMockfeed(ctrl) feed.EXPECT().CreateFeed(gomock.Any(), gomock.Eq(req)).Times(1).Return("456", nil) - srv := httptest.NewServer(MakeHandlers(feed)) + srv := httptest.NewServer(MakeHandlers(feed, cfg)) defer srv.Close() query := `{"url": "https://youtube.com/channel/123", "page_size": 55, "quality": "low", "format": "audio"}` - resp, err := http.Post(srv.URL+"/create", "application/json", strings.NewReader(query)) + resp, err := http.Post(srv.URL+"/api/create", "application/json", strings.NewReader(query)) require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode) @@ -44,46 +47,46 @@ func TestCreateInvalidFeed(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - srv := httptest.NewServer(MakeHandlers(NewMockfeed(ctrl))) + srv := httptest.NewServer(MakeHandlers(NewMockfeed(ctrl), cfg)) defer srv.Close() query := `{}` - resp, err := http.Post(srv.URL+"/create", "application/json", strings.NewReader(query)) + resp, err := http.Post(srv.URL+"/api/create", "application/json", strings.NewReader(query)) require.NoError(t, err) require.Equal(t, http.StatusBadRequest, resp.StatusCode) query = `{"url": "not a url", "page_size": 55, "quality": "low", "format": "audio"}` - resp, err = http.Post(srv.URL+"/create", "application/json", strings.NewReader(query)) + resp, err = http.Post(srv.URL+"/api/create", "application/json", strings.NewReader(query)) require.NoError(t, err) require.Equal(t, http.StatusBadRequest, resp.StatusCode) query = `{"url": "https://youtube.com/channel/123", "page_size": 1, "quality": "low", "format": "audio"}` - resp, err = http.Post(srv.URL+"/create", "application/json", strings.NewReader(query)) + resp, err = http.Post(srv.URL+"/api/create", "application/json", strings.NewReader(query)) require.NoError(t, err) require.Equal(t, http.StatusBadRequest, resp.StatusCode) query = `{"url": "https://youtube.com/channel/123", "page_size": 151, "quality": "low", "format": "audio"}` - resp, err = http.Post(srv.URL+"/create", "application/json", strings.NewReader(query)) + resp, err = http.Post(srv.URL+"/api/create", "application/json", strings.NewReader(query)) require.NoError(t, err) require.Equal(t, http.StatusBadRequest, resp.StatusCode) query = `{"url": "https://youtube.com/channel/123", "page_size": 50, "quality": "xyz", "format": "audio"}` - resp, err = http.Post(srv.URL+"/create", "application/json", strings.NewReader(query)) + resp, err = http.Post(srv.URL+"/api/create", "application/json", strings.NewReader(query)) require.NoError(t, err) require.Equal(t, http.StatusBadRequest, resp.StatusCode) query = `{"url": "https://youtube.com/channel/123", "page_size": 50, "quality": "low", "format": "xyz"}` - resp, err = http.Post(srv.URL+"/create", "application/json", strings.NewReader(query)) + resp, err = http.Post(srv.URL+"/api/create", "application/json", strings.NewReader(query)) require.NoError(t, err) require.Equal(t, http.StatusBadRequest, resp.StatusCode) query = `{"url": "https://youtube.com/channel/123", "page_size": 50, "quality": "low", "format": ""}` - resp, err = http.Post(srv.URL+"/create", "application/json", strings.NewReader(query)) + resp, err = http.Post(srv.URL+"/api/create", "application/json", strings.NewReader(query)) require.NoError(t, err) require.Equal(t, http.StatusBadRequest, resp.StatusCode) query = `{"url": "https://youtube.com/channel/123", "page_size": 50, "quality": "", "format": "audio"}` - resp, err = http.Post(srv.URL+"/create", "application/json", strings.NewReader(query)) + resp, err = http.Post(srv.URL+"/api/create", "application/json", strings.NewReader(query)) require.NoError(t, err) require.Equal(t, http.StatusBadRequest, resp.StatusCode) } @@ -97,10 +100,10 @@ func TestGetFeed(t *testing.T) { feed := NewMockfeed(ctrl) feed.EXPECT().GetFeed("123").Return(&podcast, nil) - srv := httptest.NewServer(MakeHandlers(feed)) + srv := httptest.NewServer(MakeHandlers(feed, cfg)) defer srv.Close() - resp, err := http.Get(srv.URL + "/feed/123") + resp, err := http.Get(srv.URL + "/api/feed/123") require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode) } @@ -112,10 +115,10 @@ func TestGetMetadata(t *testing.T) { feed := NewMockfeed(ctrl) feed.EXPECT().GetMetadata("123").Times(1).Return(&api.Feed{}, nil) - srv := httptest.NewServer(MakeHandlers(feed)) + srv := httptest.NewServer(MakeHandlers(feed, cfg)) defer srv.Close() - resp, err := http.Get(srv.URL + "/metadata/123") + resp, err := http.Get(srv.URL + "/api/metadata/123") require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode) } diff --git a/templates/index.html b/templates/index.html index 6e1b992..f4e8b48 100644 --- a/templates/index.html +++ b/templates/index.html @@ -28,9 +28,9 @@