1
0
mirror of https://github.com/mxpv/podsync.git synced 2024-05-11 05:55:04 +00:00

Implement login via Patreon

This commit is contained in:
Maksym Pavlenko
2017-08-20 16:01:30 -07:00
parent a243cef707
commit c0e8f7aa8c
8 changed files with 167 additions and 48 deletions

View File

@@ -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"
revision = "c6ced3330f133cec79e144fb11b2e4c5154059ae"
[[constraint]]
name = "github.com/gin-gonic/gin"
version = "1.2"
[[constraint]]
name = "github.com/gin-contrib/sessions"
revision = "a71ea9167c616cf9f02bc928ed08bc390c8279bc"

View File

@@ -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() {

View File

@@ -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"`
}

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -28,9 +28,9 @@
</h2>
<div class="login-block">
{{if not .identity }}
{{if .UserId }}
<p>
Yo, <i class="fa fa-user-circle" aria-hidden="true"></i> @User.GetName() ( <a href="/logout"><i class="fa fa-sign-out" aria-hidden="true"></i>logout</a>)
Yo, <i class="fa fa-user-circle" aria-hidden="true"></i> {{ .FullName }} ( <a href="/logout"><i class="fa fa-sign-out" aria-hidden="true"></i>logout</a>)
</p>
{{else}}
<h5>
@@ -57,7 +57,7 @@
<div class="controls">
<p>
{{if .enableFeatures }}
{{if gt .FeatureLevel 0 }}
<i class="fa fa-wrench" aria-hidden="true"></i>
{{else}}
<i class="fa fa-question-circle master-tooltip"
@@ -65,7 +65,7 @@
title="This features are available for patrons only. You may support us and unlock this features"></i>
{{end}}
<span class="{{if not .enableFeatures }}locked{{end}}">
<span class="{{if eq .FeatureLevel 0 }}locked{{end}}">
<span id="control-panel">
Selected format <a class="selected-option" id="video-format">video</a> <a id="audio-format">audio</a>,
quality <a class="selected-option" id="best-quality">best</a> <a id="worst-quality">worst</a>,