diff --git a/.travis.yml b/.travis.yml index 3edb6e3..a08964c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -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) diff --git a/Gopkg.toml b/Gopkg.toml index cef0d61..5ceb3df 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -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" diff --git a/cmd/app/main.go b/cmd/app/main.go index a187158..0441eb1 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -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 +} diff --git a/docker-compose.yml b/docker-compose.yml index 13e7a39..215877f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/pkg/config/config.go b/pkg/config/config.go index 8719a55..dadf2e8 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -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", diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 437a495..e80868c 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -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) } diff --git a/pkg/handler/handler.go b/pkg/handler/handler.go new file mode 100644 index 0000000..3f360ef --- /dev/null +++ b/pkg/handler/handler.go @@ -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()} +} diff --git a/pkg/server/server_mock_test.go b/pkg/handler/handler_mock_test.go similarity index 98% rename from pkg/server/server_mock_test.go rename to pkg/handler/handler_mock_test.go index a808718..9c88046 100644 --- a/pkg/server/server_mock_test.go +++ b/pkg/handler/handler_mock_test.go @@ -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" diff --git a/pkg/server/server_test.go b/pkg/handler/handler_test.go similarity index 92% rename from pkg/server/server_test.go rename to pkg/handler/handler_test.go index 7a5864d..b0e04a3 100644 --- a/pkg/server/server_test.go +++ b/pkg/handler/handler_test.go @@ -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") diff --git a/pkg/models/models.go b/pkg/models/models.go new file mode 100644 index 0000000..96f7afd --- /dev/null +++ b/pkg/models/models.go @@ -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 +} diff --git a/pkg/models/pg.sql b/pkg/models/pg.sql new file mode 100644 index 0000000..f05d0cd --- /dev/null +++ b/pkg/models/pg.sql @@ -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; \ No newline at end of file diff --git a/pkg/server/server.go b/pkg/server/server.go deleted file mode 100644 index 2d292a1..0000000 --- a/pkg/server/server.go +++ /dev/null @@ -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()} -} diff --git a/pkg/webhook/hook.go b/pkg/webhook/hook.go new file mode 100644 index 0000000..d500d46 --- /dev/null +++ b/pkg/webhook/hook.go @@ -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} +} diff --git a/pkg/webhook/hook_test.go b/pkg/webhook/hook_test.go new file mode 100644 index 0000000..cb05e79 --- /dev/null +++ b/pkg/webhook/hook_test.go @@ -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 +}