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:
31
Gopkg.toml
31
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"
|
||||
revision = "c6ced3330f133cec79e144fb11b2e4c5154059ae"
|
||||
|
||||
[[constraint]]
|
||||
name = "github.com/gin-gonic/gin"
|
||||
version = "1.2"
|
||||
|
||||
[[constraint]]
|
||||
name = "github.com/gin-contrib/sessions"
|
||||
revision = "a71ea9167c616cf9f02bc928ed08bc390c8279bc"
|
@@ -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() {
|
||||
|
@@ -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"`
|
||||
}
|
||||
|
@@ -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 {
|
||||
|
@@ -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)
|
||||
}
|
||||
|
@@ -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)
|
||||
}
|
||||
|
@@ -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)
|
||||
}
|
||||
|
@@ -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>,
|
||||
|
Reference in New Issue
Block a user