mirror of
https://github.com/mxpv/podsync.git
synced 2024-05-11 05:55:04 +00:00
@@ -13,6 +13,7 @@ addons:
|
||||
postgresql: "9.6"
|
||||
before_script:
|
||||
- psql -a -c "CREATE DATABASE podsync;" -U postgres
|
||||
- psql -a -f ./pkg/models/pg.sql -d podsync -U postgres
|
||||
script:
|
||||
- set -e
|
||||
- go test -v -short $(go list -e ./... | grep -v vendor)
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@
|
||||
|
||||
[[constraint]]
|
||||
name = "github.com/mxpv/patreon-go"
|
||||
version = "1.2"
|
||||
version = "1.3"
|
||||
|
||||
[[constraint]]
|
||||
name = "github.com/ventu-io/go-shortid"
|
||||
|
||||
+38
-2
@@ -4,18 +4,23 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/GoogleCloudPlatform/cloudsql-proxy/proxy/proxy"
|
||||
"github.com/go-pg/pg"
|
||||
"github.com/mxpv/podsync/pkg/api"
|
||||
"github.com/mxpv/podsync/pkg/builders"
|
||||
"github.com/mxpv/podsync/pkg/config"
|
||||
"github.com/mxpv/podsync/pkg/feeds"
|
||||
"github.com/mxpv/podsync/pkg/handler"
|
||||
"github.com/mxpv/podsync/pkg/id"
|
||||
"github.com/mxpv/podsync/pkg/server"
|
||||
"github.com/mxpv/podsync/pkg/storage"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -42,6 +47,11 @@ func main() {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
pg, err := createPg(cfg.PostgresConnectionURL)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Builders
|
||||
|
||||
youtube, err := builders.NewYouTubeBuilder(cfg.YouTubeApiKey)
|
||||
@@ -63,7 +73,7 @@ func main() {
|
||||
|
||||
srv := http.Server{
|
||||
Addr: fmt.Sprintf(":%d", 5001),
|
||||
Handler: server.MakeHandlers(feed, cfg),
|
||||
Handler: handler.New(feed, pg, cfg),
|
||||
}
|
||||
|
||||
go func() {
|
||||
@@ -81,3 +91,29 @@ func main() {
|
||||
|
||||
log.Printf("server gracefully stopped")
|
||||
}
|
||||
|
||||
func createPg(connectionURL string) (*pg.DB, error) {
|
||||
opts, err := pg.ParseURL(connectionURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If host format is "projection:region:host", than use Google SQL Proxy
|
||||
// See https://github.com/go-pg/pg/issues/576
|
||||
if strings.Count(opts.Addr, ":") == 2 {
|
||||
log.Print("using GCP SQL proxy")
|
||||
opts.Dialer = func(network, addr string) (net.Conn, error) {
|
||||
return proxy.Dial(addr)
|
||||
}
|
||||
}
|
||||
|
||||
db := pg.Connect(opts)
|
||||
|
||||
// Check database connectivity
|
||||
if _, err := db.ExecOne("SELECT 1"); err != nil {
|
||||
db.Close()
|
||||
return nil, errors.Wrap(err, "failed to check database connectivity")
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
@@ -9,11 +9,15 @@ services:
|
||||
- 5001
|
||||
environment:
|
||||
- REDIS_CONNECTION_URL=redis://redis
|
||||
- POSTGRES_CONNECTION_URL={POSTGRES_CONNECTION_URL}
|
||||
# https://console.developers.google.com/project/_/apiui/credential/serviceaccount
|
||||
- GOOGLE_APPLICATION_CREDENTIALS={PATH_TO_GOOGLE_CREDENTIALS_FILE_FOR_USE_OUTSIDE_GOOGLE_CLOUD}
|
||||
- YOUTUBE_API_KEY={YOUTUBE_API_KEY}
|
||||
- VIMEO_API_KEY={VIMEO_API_KEY}
|
||||
- PATREON_CLIENT_ID={PATREON_CLIENT_ID}
|
||||
- PATREON_SECRET={PATREON_SECRET}
|
||||
- PATREON_REDIRECT_URL=https://podsync.net/patreon
|
||||
- PATREON_WEBHOOKS_SECRET={PATREON_WEBHOOKS_SECRET}
|
||||
- COOKIE_SECRET={COOKIE_SECRET}
|
||||
ytdl:
|
||||
image: gcr.io/pod-sync/ytdl:latest
|
||||
|
||||
@@ -14,6 +14,7 @@ type AppConfig struct {
|
||||
PatreonClientId string `yaml:"patreonClientId"`
|
||||
PatreonSecret string `yaml:"patreonSecret"`
|
||||
PatreonRedirectURL string `yaml:"patreonRedirectUrl"`
|
||||
PatreonWebhooksSecret string `json:"patreonWebhooksSecret"`
|
||||
PostgresConnectionURL string `yaml:"postgresConnectionUrl"`
|
||||
RedisURL string `yaml:"redisUrl"`
|
||||
CookieSecret string `yaml:"cookieSecret"`
|
||||
@@ -38,6 +39,7 @@ func ReadConfiguration() (cfg *AppConfig, err error) {
|
||||
"patreonClientId": "PATREON_CLIENT_ID",
|
||||
"patreonSecret": "PATREON_SECRET",
|
||||
"patreonRedirectUrl": "PATREON_REDIRECT_URL",
|
||||
"patreonWebhooksSecret": "PATREON_WEBHOOKS_SECRET",
|
||||
"postgresConnectionUrl": "POSTGRES_CONNECTION_URL",
|
||||
"redisUrl": "REDIS_CONNECTION_URL",
|
||||
"cookieSecret": "COOKIE_SECRET",
|
||||
|
||||
@@ -19,6 +19,7 @@ cookieSecret: "6"
|
||||
patreonRedirectUrl: "7"
|
||||
assetsPath: "8"
|
||||
templatesPath: "9"
|
||||
patreonWebhooksSecret: "10"
|
||||
`
|
||||
|
||||
func TestReadYaml(t *testing.T) {
|
||||
@@ -40,6 +41,7 @@ func TestReadYaml(t *testing.T) {
|
||||
require.Equal(t, "7", cfg.PatreonRedirectURL)
|
||||
require.Equal(t, "8", cfg.AssetsPath)
|
||||
require.Equal(t, "9", cfg.TemplatesPath)
|
||||
require.Equal(t, "10", cfg.PatreonWebhooksSecret)
|
||||
}
|
||||
|
||||
func TestReadEnv(t *testing.T) {
|
||||
@@ -55,6 +57,7 @@ func TestReadEnv(t *testing.T) {
|
||||
os.Setenv("PATREON_REDIRECT_URL", "77")
|
||||
os.Setenv("ASSETS_PATH", "88")
|
||||
os.Setenv("TEMPLATES_PATH", "99")
|
||||
os.Setenv("PATREON_WEBHOOKS_SECRET", "1010")
|
||||
|
||||
cfg, err := ReadConfiguration()
|
||||
require.NoError(t, err)
|
||||
@@ -68,4 +71,5 @@ func TestReadEnv(t *testing.T) {
|
||||
require.Equal(t, "77", cfg.PatreonRedirectURL)
|
||||
require.Equal(t, "88", cfg.AssetsPath)
|
||||
require.Equal(t, "99", cfg.TemplatesPath)
|
||||
require.Equal(t, "1010", cfg.PatreonWebhooksSecret)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-pg/pg"
|
||||
"github.com/mxpv/patreon-go"
|
||||
itunes "github.com/mxpv/podcast"
|
||||
"github.com/mxpv/podsync/pkg/api"
|
||||
"github.com/mxpv/podsync/pkg/config"
|
||||
"github.com/mxpv/podsync/pkg/session"
|
||||
"github.com/mxpv/podsync/pkg/webhook"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
const (
|
||||
creatorID = "2822191"
|
||||
maxHashIDLength = 16
|
||||
)
|
||||
|
||||
type feed interface {
|
||||
CreateFeed(req *api.CreateFeedRequest, identity *api.Identity) (string, error)
|
||||
GetFeed(hashId string) (*itunes.Podcast, error)
|
||||
GetMetadata(hashId string) (*api.Feed, error)
|
||||
}
|
||||
|
||||
type handler struct {
|
||||
feed feed
|
||||
cfg *config.AppConfig
|
||||
oauth2 oauth2.Config
|
||||
hook *webhook.Handler
|
||||
}
|
||||
|
||||
func (h handler) index(c *gin.Context) {
|
||||
identity, err := session.GetIdentity(c)
|
||||
if err != nil {
|
||||
identity = &api.Identity{}
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "index.html", identity)
|
||||
}
|
||||
|
||||
func (h handler) login(c *gin.Context) {
|
||||
state, err := session.SetState(c)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
authURL := h.oauth2.AuthCodeURL(state)
|
||||
c.Redirect(http.StatusFound, authURL)
|
||||
}
|
||||
|
||||
func (h handler) logout(c *gin.Context) {
|
||||
session.Clear(c)
|
||||
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
}
|
||||
|
||||
func (h handler) patreonCallback(c *gin.Context) {
|
||||
// Validate session state
|
||||
if session.GetSetate(c) != c.Query("state") {
|
||||
c.String(http.StatusUnauthorized, "invalid state")
|
||||
return
|
||||
}
|
||||
|
||||
// Exchange code with tokens
|
||||
token, err := h.oauth2.Exchange(c.Request.Context(), c.Query("code"))
|
||||
if err != nil {
|
||||
c.String(http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Create Patreon client
|
||||
tc := h.oauth2.Client(c.Request.Context(), token)
|
||||
client := patreon.NewClient(tc)
|
||||
|
||||
// Query user info from Patreon
|
||||
user, err := client.FetchUser()
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Determine feature level
|
||||
level := api.DefaultFeatures
|
||||
|
||||
if user.Data.ID == creatorID {
|
||||
level = api.PodcasterFeature
|
||||
} else {
|
||||
amount := 0
|
||||
for _, item := range user.Included.Items {
|
||||
pledge, ok := item.(*patreon.Pledge)
|
||||
if ok {
|
||||
amount += pledge.Attributes.AmountCents
|
||||
}
|
||||
}
|
||||
|
||||
if amount >= 100 {
|
||||
level = api.ExtendedFeatures
|
||||
}
|
||||
}
|
||||
|
||||
identity := &api.Identity{
|
||||
UserId: user.Data.ID,
|
||||
FullName: user.Data.Attributes.FullName,
|
||||
Email: user.Data.Attributes.Email,
|
||||
ProfileURL: user.Data.Attributes.URL,
|
||||
FeatureLevel: level,
|
||||
}
|
||||
|
||||
session.SetIdentity(c, identity)
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
}
|
||||
|
||||
func (h handler) robots(c *gin.Context) {
|
||||
c.String(http.StatusOK, `User-agent: *
|
||||
Allow: /$
|
||||
Disallow: /
|
||||
Host: www.podsync.net`)
|
||||
}
|
||||
|
||||
func (h handler) ping(c *gin.Context) {
|
||||
c.String(http.StatusOK, "ok")
|
||||
}
|
||||
|
||||
func (h handler) create(c *gin.Context) {
|
||||
req := &api.CreateFeedRequest{}
|
||||
|
||||
if err := c.BindJSON(req); err != nil {
|
||||
c.JSON(badRequest(err))
|
||||
return
|
||||
}
|
||||
|
||||
identity, err := session.GetIdentity(c)
|
||||
if err != nil {
|
||||
c.JSON(internalError(err))
|
||||
return
|
||||
}
|
||||
|
||||
hashId, err := h.feed.CreateFeed(req, identity)
|
||||
if err != nil {
|
||||
c.JSON(internalError(err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"id": hashId})
|
||||
}
|
||||
|
||||
func (h handler) getFeed(c *gin.Context) {
|
||||
hashId := c.Request.URL.Path[1:]
|
||||
if hashId == "" || len(hashId) > maxHashIDLength {
|
||||
c.String(http.StatusBadRequest, "invalid feed id")
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasSuffix(hashId, ".xml") {
|
||||
hashId = strings.TrimSuffix(hashId, ".xml")
|
||||
}
|
||||
|
||||
podcast, err := h.feed.GetFeed(hashId)
|
||||
if err != nil {
|
||||
code := http.StatusInternalServerError
|
||||
if err == api.ErrNotFound {
|
||||
code = http.StatusNotFound
|
||||
} else {
|
||||
log.Printf("server error (hash id: %s): %v", hashId, err)
|
||||
}
|
||||
|
||||
c.String(code, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
c.Data(http.StatusOK, "application/rss+xml; charset=UTF-8", podcast.Bytes())
|
||||
}
|
||||
|
||||
func (h handler) metadata(c *gin.Context) {
|
||||
hashId := c.Param("hashId")
|
||||
if hashId == "" || len(hashId) > maxHashIDLength {
|
||||
c.String(http.StatusBadRequest, "invalid feed id")
|
||||
return
|
||||
}
|
||||
|
||||
feed, err := h.feed.GetMetadata(hashId)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, feed)
|
||||
}
|
||||
|
||||
func (h handler) webhook(c *gin.Context) {
|
||||
// Read body to byte array in order to verify signature first
|
||||
body, err := ioutil.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
log.Printf("failed to read webhook body: %v", err)
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify signature
|
||||
signature := c.GetHeader(patreon.HeaderSignature)
|
||||
valid, err := patreon.VerifySignature(body, h.cfg.PatreonWebhooksSecret, signature)
|
||||
if err != nil {
|
||||
log.Printf("failed to verify signature: %v", err)
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if !valid {
|
||||
c.Status(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Get event name
|
||||
eventName := c.GetHeader(patreon.HeaderEventType)
|
||||
if eventName == "" {
|
||||
log.Print("event name header is empty")
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
pledge := &patreon.WebhookPledge{}
|
||||
if err := json.Unmarshal(body, pledge); err != nil {
|
||||
c.JSON(badRequest(err))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.hook.Handle(&pledge.Data, eventName); err != nil {
|
||||
c.JSON(internalError(err))
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("sucessfully processed patreon event %s (%s)", pledge.Data.ID, eventName)
|
||||
}
|
||||
|
||||
func New(feed feed, db *pg.DB, 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
|
||||
|
||||
log.Printf("using assets path: %s", cfg.AssetsPath)
|
||||
if cfg.AssetsPath != "" {
|
||||
r.Static("/assets", cfg.AssetsPath)
|
||||
}
|
||||
|
||||
log.Printf("using templates path: %s", cfg.TemplatesPath)
|
||||
if cfg.TemplatesPath != "" {
|
||||
r.LoadHTMLGlob(path.Join(cfg.TemplatesPath, "*.html"))
|
||||
}
|
||||
|
||||
h := handler{
|
||||
feed: feed,
|
||||
cfg: cfg,
|
||||
hook: webhook.NewHookHandler(db),
|
||||
}
|
||||
|
||||
// OAuth 2 configuration
|
||||
|
||||
h.oauth2 = oauth2.Config{
|
||||
ClientID: cfg.PatreonClientId,
|
||||
ClientSecret: cfg.PatreonSecret,
|
||||
RedirectURL: cfg.PatreonRedirectURL,
|
||||
Scopes: []string{"users", "pledges-to-me", "my-campaign"},
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: patreon.AuthorizationURL,
|
||||
TokenURL: patreon.AccessTokenURL,
|
||||
},
|
||||
}
|
||||
|
||||
// Handlers
|
||||
|
||||
r.GET("/", h.index)
|
||||
r.GET("/login", h.login)
|
||||
r.GET("/logout", h.logout)
|
||||
r.GET("/patreon", h.patreonCallback)
|
||||
r.GET("/robots.txt", h.robots)
|
||||
|
||||
r.GET("/api/ping", h.ping)
|
||||
r.POST("/api/create", h.create)
|
||||
r.GET("/api/metadata/:hashId", h.metadata)
|
||||
r.POST("/api/webhooks", h.webhook)
|
||||
|
||||
r.NoRoute(h.getFeed)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func badRequest(err error) (int, interface{}) {
|
||||
return http.StatusBadRequest, gin.H{"error": err.Error()}
|
||||
}
|
||||
|
||||
func internalError(err error) (int, interface{}) {
|
||||
log.Printf("server error: %v", err)
|
||||
return http.StatusInternalServerError, gin.H{"error": err.Error()}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: server.go
|
||||
// Source: handler.go
|
||||
|
||||
package server
|
||||
package handler
|
||||
|
||||
import (
|
||||
gomock "github.com/golang/mock/gomock"
|
||||
@@ -1,6 +1,6 @@
|
||||
//go:generate mockgen -source=server.go -destination=server_mock_test.go -package=server
|
||||
//go:generate mockgen -source=handler.go -destination=handler_mock_test.go -package=handler
|
||||
|
||||
package server
|
||||
package handler
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
@@ -32,7 +32,7 @@ func TestCreateFeed(t *testing.T) {
|
||||
feed := NewMockfeed(ctrl)
|
||||
feed.EXPECT().CreateFeed(gomock.Eq(req), gomock.Any()).Times(1).Return("456", nil)
|
||||
|
||||
srv := httptest.NewServer(MakeHandlers(feed, cfg))
|
||||
srv := httptest.NewServer(New(feed, nil, cfg))
|
||||
defer srv.Close()
|
||||
|
||||
query := `{"url": "https://youtube.com/channel/123", "page_size": 55, "quality": "low", "format": "audio"}`
|
||||
@@ -47,7 +47,7 @@ func TestCreateInvalidFeed(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
srv := httptest.NewServer(MakeHandlers(NewMockfeed(ctrl), cfg))
|
||||
srv := httptest.NewServer(New(NewMockfeed(ctrl), nil, cfg))
|
||||
defer srv.Close()
|
||||
|
||||
query := `{}`
|
||||
@@ -100,7 +100,7 @@ func TestGetFeed(t *testing.T) {
|
||||
feed := NewMockfeed(ctrl)
|
||||
feed.EXPECT().GetFeed("123").Return(&podcast, nil)
|
||||
|
||||
srv := httptest.NewServer(MakeHandlers(feed, cfg))
|
||||
srv := httptest.NewServer(New(feed, nil, cfg))
|
||||
defer srv.Close()
|
||||
|
||||
resp, err := http.Get(srv.URL + "/123")
|
||||
@@ -115,7 +115,7 @@ func TestGetMetadata(t *testing.T) {
|
||||
feed := NewMockfeed(ctrl)
|
||||
feed.EXPECT().GetMetadata("123").Times(1).Return(&api.Feed{}, nil)
|
||||
|
||||
srv := httptest.NewServer(MakeHandlers(feed, cfg))
|
||||
srv := httptest.NewServer(New(feed, nil, cfg))
|
||||
defer srv.Close()
|
||||
|
||||
resp, err := http.Get(srv.URL + "/api/metadata/123")
|
||||
@@ -0,0 +1,14 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type Pledge struct {
|
||||
PledgeID int64 `sql:",pk"`
|
||||
PatronID int64
|
||||
CreatedAt time.Time
|
||||
DeclinedSince time.Time
|
||||
AmountCents int
|
||||
TotalHistoricalAmountCents int
|
||||
OutstandingPaymentAmountCents int
|
||||
IsPaused bool
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pledges (
|
||||
pledge_id BIGSERIAL PRIMARY KEY,
|
||||
patron_id BIGINT NOT NULL ,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
declined_since TIMESTAMPTZ NULL,
|
||||
amount_cents INT NOT NULL,
|
||||
total_historical_amount_cents INT,
|
||||
outstanding_payment_amount_cents INT,
|
||||
is_paused BOOLEAN
|
||||
);
|
||||
|
||||
CREATE INDEX patron_id_idx ON pledges(patron_id);
|
||||
|
||||
COMMIT;
|
||||
@@ -1,232 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"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/mxpv/podsync/pkg/session"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
const (
|
||||
creatorID = "2822191"
|
||||
maxHashIDLength = 16
|
||||
)
|
||||
|
||||
type feed interface {
|
||||
CreateFeed(req *api.CreateFeedRequest, identity *api.Identity) (string, error)
|
||||
GetFeed(hashId string) (*itunes.Podcast, error)
|
||||
GetMetadata(hashId string) (*api.Feed, error)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
log.Printf("using assets path: %s", cfg.AssetsPath)
|
||||
if cfg.AssetsPath != "" {
|
||||
r.Static("/assets", cfg.AssetsPath)
|
||||
}
|
||||
|
||||
log.Printf("using templates path: %s", cfg.TemplatesPath)
|
||||
if cfg.TemplatesPath != "" {
|
||||
r.LoadHTMLGlob(path.Join(cfg.TemplatesPath, "*.html"))
|
||||
}
|
||||
|
||||
conf := &oauth2.Config{
|
||||
ClientID: cfg.PatreonClientId,
|
||||
ClientSecret: cfg.PatreonSecret,
|
||||
RedirectURL: cfg.PatreonRedirectURL,
|
||||
Scopes: []string{"users", "pledges-to-me", "my-campaign"},
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: patreon.AuthorizationURL,
|
||||
TokenURL: patreon.AccessTokenURL,
|
||||
},
|
||||
}
|
||||
|
||||
r.GET("/", func(c *gin.Context) {
|
||||
identity, err := session.GetIdentity(c)
|
||||
if err != nil {
|
||||
identity = &api.Identity{}
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "index.html", identity)
|
||||
})
|
||||
|
||||
r.GET("/login", func(c *gin.Context) {
|
||||
state, err := session.SetState(c)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
authURL := conf.AuthCodeURL(state)
|
||||
c.Redirect(http.StatusFound, authURL)
|
||||
})
|
||||
|
||||
r.GET("/logout", func(c *gin.Context) {
|
||||
session.Clear(c)
|
||||
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
})
|
||||
|
||||
r.GET("/patreon", func(c *gin.Context) {
|
||||
// Validate session state
|
||||
if session.GetSetate(c) != 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()
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Determine feature level
|
||||
level := api.DefaultFeatures
|
||||
|
||||
if user.Data.Id == creatorID {
|
||||
level = api.PodcasterFeature
|
||||
} else {
|
||||
amount := 0
|
||||
for _, item := range user.Included.Items {
|
||||
pledge, ok := item.(*patreon.Pledge)
|
||||
if ok {
|
||||
amount += pledge.Attributes.AmountCents
|
||||
}
|
||||
}
|
||||
|
||||
if amount >= 100 {
|
||||
level = api.ExtendedFeatures
|
||||
}
|
||||
}
|
||||
|
||||
identity := &api.Identity{
|
||||
UserId: user.Data.Id,
|
||||
FullName: user.Data.Attributes.FullName,
|
||||
Email: user.Data.Attributes.Email,
|
||||
ProfileURL: user.Data.Attributes.URL,
|
||||
FeatureLevel: level,
|
||||
}
|
||||
|
||||
session.SetIdentity(c, identity)
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
})
|
||||
|
||||
// GET /robots.txt
|
||||
r.GET("/robots.txt", func(c *gin.Context) {
|
||||
c.String(http.StatusOK, `User-agent: *
|
||||
Allow: /$
|
||||
Disallow: /
|
||||
Host: www.podsync.net`)
|
||||
})
|
||||
|
||||
// REST API
|
||||
|
||||
r.GET("/api/ping", func(c *gin.Context) {
|
||||
c.String(http.StatusOK, "ok")
|
||||
})
|
||||
|
||||
r.POST("/api/create", func(c *gin.Context) {
|
||||
req := &api.CreateFeedRequest{}
|
||||
|
||||
if err := c.BindJSON(req); err != nil {
|
||||
c.JSON(badRequest(err))
|
||||
return
|
||||
}
|
||||
|
||||
identity, err := session.GetIdentity(c)
|
||||
if err != nil {
|
||||
c.JSON(internalError(err))
|
||||
return
|
||||
}
|
||||
|
||||
hashId, err := feed.CreateFeed(req, identity)
|
||||
if err != nil {
|
||||
c.JSON(internalError(err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"id": hashId})
|
||||
})
|
||||
|
||||
r.NoRoute(func(c *gin.Context) {
|
||||
hashId := c.Request.URL.Path[1:]
|
||||
if hashId == "" || len(hashId) > maxHashIDLength {
|
||||
c.String(http.StatusBadRequest, "invalid feed id")
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasSuffix(hashId, ".xml") {
|
||||
hashId = strings.TrimSuffix(hashId, ".xml")
|
||||
}
|
||||
|
||||
podcast, err := feed.GetFeed(hashId)
|
||||
if err != nil {
|
||||
code := http.StatusInternalServerError
|
||||
if err == api.ErrNotFound {
|
||||
code = http.StatusNotFound
|
||||
} else {
|
||||
log.Printf("server error (hash id: %s): %v", hashId, err)
|
||||
}
|
||||
|
||||
c.String(code, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
c.Data(http.StatusOK, "application/rss+xml; charset=UTF-8", podcast.Bytes())
|
||||
})
|
||||
|
||||
r.GET("/api/metadata/:hashId", func(c *gin.Context) {
|
||||
hashId := c.Param("hashId")
|
||||
if hashId == "" || len(hashId) > maxHashIDLength {
|
||||
c.String(http.StatusBadRequest, "invalid feed id")
|
||||
return
|
||||
}
|
||||
|
||||
feed, err := feed.GetMetadata(hashId)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, feed)
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func badRequest(err error) (int, interface{}) {
|
||||
return http.StatusBadRequest, gin.H{"error": err.Error()}
|
||||
}
|
||||
|
||||
func internalError(err error) (int, interface{}) {
|
||||
log.Printf("server error: %v", err)
|
||||
return http.StatusInternalServerError, gin.H{"error": err.Error()}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package webhook
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-pg/pg"
|
||||
"github.com/mxpv/patreon-go"
|
||||
"github.com/mxpv/podsync/pkg/models"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
db *pg.DB
|
||||
}
|
||||
|
||||
func (h Handler) toModel(pledge *patreon.Pledge) (*models.Pledge, error) {
|
||||
pledgeID, err := strconv.ParseInt(pledge.ID, 10, 64)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "failed to parse pledge id: %s", pledge.ID)
|
||||
}
|
||||
|
||||
patronID, err := strconv.ParseInt(pledge.Relationships.Patron.Data.ID, 10, 64)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "failed to parse patron id: %s", pledge.Relationships.Patron.Data.ID)
|
||||
}
|
||||
|
||||
model := &models.Pledge{
|
||||
PledgeID: pledgeID,
|
||||
PatronID: patronID,
|
||||
AmountCents: pledge.Attributes.AmountCents,
|
||||
}
|
||||
|
||||
if pledge.Attributes.CreatedAt.Valid {
|
||||
model.CreatedAt = pledge.Attributes.CreatedAt.Time
|
||||
}
|
||||
|
||||
if pledge.Attributes.DeclinedSince.Valid {
|
||||
model.DeclinedSince = pledge.Attributes.DeclinedSince.Time
|
||||
}
|
||||
|
||||
// Read optional fields
|
||||
|
||||
if pledge.Attributes.TotalHistoricalAmountCents != nil {
|
||||
model.TotalHistoricalAmountCents = *pledge.Attributes.TotalHistoricalAmountCents
|
||||
}
|
||||
|
||||
if pledge.Attributes.OutstandingPaymentAmountCents != nil {
|
||||
model.OutstandingPaymentAmountCents = *pledge.Attributes.OutstandingPaymentAmountCents
|
||||
}
|
||||
|
||||
if pledge.Attributes.IsPaused != nil {
|
||||
model.IsPaused = *pledge.Attributes.IsPaused
|
||||
}
|
||||
|
||||
return model, nil
|
||||
}
|
||||
|
||||
func (h Handler) Handle(pledge *patreon.Pledge, event string) error {
|
||||
model, err := h.toModel(pledge)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch event {
|
||||
case patreon.EventCreatePledge:
|
||||
return h.db.Insert(model)
|
||||
case patreon.EventUpdatePledge:
|
||||
return h.db.Update(model)
|
||||
case patreon.EventDeletePledge:
|
||||
return h.db.Delete(model)
|
||||
default:
|
||||
return fmt.Errorf("unknown event: %s", event)
|
||||
}
|
||||
}
|
||||
|
||||
func NewHookHandler(db *pg.DB) *Handler {
|
||||
return &Handler{db: db}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package webhook
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-pg/pg"
|
||||
"github.com/mxpv/patreon-go"
|
||||
"github.com/mxpv/podsync/pkg/models"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCreate(t *testing.T) {
|
||||
pledge := createPledge()
|
||||
|
||||
hook := createHandler(t)
|
||||
err := hook.Handle(pledge, patreon.EventCreatePledge)
|
||||
require.NoError(t, err)
|
||||
|
||||
model := &models.Pledge{PledgeID: 12345}
|
||||
err = hook.db.Select(model)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, pledge.Attributes.AmountCents, model.AmountCents)
|
||||
}
|
||||
|
||||
func TestUpdate(t *testing.T) {
|
||||
pledge := createPledge()
|
||||
|
||||
hook := createHandler(t)
|
||||
err := hook.Handle(pledge, patreon.EventCreatePledge)
|
||||
require.NoError(t, err)
|
||||
|
||||
pledge.Attributes.AmountCents = 999
|
||||
|
||||
err = hook.Handle(pledge, patreon.EventUpdatePledge)
|
||||
require.NoError(t, err)
|
||||
|
||||
model := &models.Pledge{PledgeID: 12345}
|
||||
err = hook.db.Select(model)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 999, model.AmountCents)
|
||||
}
|
||||
|
||||
func TestDelete(t *testing.T) {
|
||||
pledge := createPledge()
|
||||
hook := createHandler(t)
|
||||
|
||||
err := hook.Handle(pledge, patreon.EventCreatePledge)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = hook.Handle(pledge, patreon.EventDeletePledge)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func createHandler(t *testing.T) *Handler {
|
||||
opts, err := pg.ParseURL("postgres://postgres:@localhost/podsync?sslmode=disable")
|
||||
if err != nil {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
db := pg.Connect(opts)
|
||||
|
||||
_, err = db.Model(&models.Pledge{}).Where("1=1").Delete()
|
||||
require.NoError(t, err)
|
||||
|
||||
return NewHookHandler(db)
|
||||
}
|
||||
|
||||
func createPledge() *patreon.Pledge {
|
||||
pledge := &patreon.Pledge{
|
||||
ID: "12345",
|
||||
Type: "pledge",
|
||||
}
|
||||
|
||||
pledge.Attributes.AmountCents = 400
|
||||
pledge.Attributes.CreatedAt = patreon.NullTime{Valid: true, Time: time.Now().UTC()}
|
||||
|
||||
pledge.Relationships.Patron = &patreon.PatronRelationship{}
|
||||
pledge.Relationships.Patron.Data.ID = "67890"
|
||||
|
||||
return pledge
|
||||
}
|
||||
Reference in New Issue
Block a user