From 857213f157129abca38253d9cd040d36e2307e1e Mon Sep 17 00:00:00 2001 From: Stefan Zermatten Date: Thu, 7 Mar 2019 13:35:31 +0200 Subject: [PATCH] Improved Patreon linking --- app/Model/Users/Users.js | 5 + app/client/views/layout/layout.html | 3 + app/client/views/layout/layout.js | 14 ++ app/client/views/user/profile/profile.html | 28 ++- app/client/views/user/profile/profile.js | 24 ++- app/server/patreon/patreon.js | 201 +++++++++++++++++---- 6 files changed, 224 insertions(+), 51 deletions(-) diff --git a/app/Model/Users/Users.js b/app/Model/Users/Users.js index 82a2ccae..4f4aaa38 100644 --- a/app/Model/Users/Users.js +++ b/app/Model/Users/Users.js @@ -86,9 +86,14 @@ Schemas.User = new SimpleSchema({ type: String, optional: true, }, + "patreon.tokenExpiryDate": { + type: Date, + optional: true, + }, "patreon.userId": { type: String, optional: true, + index: 1, }, "patreon.entitledCents": { type: Number, diff --git a/app/client/views/layout/layout.html b/app/client/views/layout/layout.html index 50d6cb0c..9bb47c02 100644 --- a/app/client/views/layout/layout.html +++ b/app/client/views/layout/layout.html @@ -21,6 +21,9 @@ {{profileLink}} + + {{patreonTier}} tier + {{else}} Sign in diff --git a/app/client/views/layout/layout.js b/app/client/views/layout/layout.js index f4937071..a18c74c8 100644 --- a/app/client/views/layout/layout.js +++ b/app/client/views/layout/layout.js @@ -17,6 +17,20 @@ Template.appDrawer.helpers({ let post = PatreonPosts.findOne({}, {sort: {date: -1}}); return (post && post.link) || 'https://www.patreon.com/dicecloud'; }, + patreonTier: function(){ + let user = Meteor.user(); + if (!user) return; + patreon = user.patreon; + if (!patreon) return "$0"; + let entitledCents = patreon.entitledCents || 0; + if (patreon.entitledCentsOverride > entitledCents){ + return "$" + (patreon.entitledCentsOverride / 100).toFixed(0); + } else if (patreon.entitledCents === undefined){ + return "$0"; + } else { + return "$" + (patreon.entitledCents / 100).toFixed(0); + } + }, }); let drawerLayout; diff --git a/app/client/views/user/profile/profile.html b/app/client/views/user/profile/profile.html index 488ec1f7..c8b455f4 100644 --- a/app/client/views/user/profile/profile.html +++ b/app/client/views/user/profile/profile.html @@ -62,16 +62,32 @@ {{/if}} + + + Patreon + + {{#if patreon.accessToken}} + + {{tier}} tier + + + + + + {{else}} + + + + Connect Patreon account + + + + {{/if}} +
{{> atForm state="signIn"}}
-
- Tier: {{tier}} - - Connect Patreon Account - -
{{> atNavButton }} diff --git a/app/client/views/user/profile/profile.js b/app/client/views/user/profile/profile.js index c1e63b09..18e303fe 100644 --- a/app/client/views/user/profile/profile.js +++ b/app/client/views/user/profile/profile.js @@ -1,9 +1,10 @@ import { format as formatUrl } from 'url'; -const CLIENT_ID = "zv38izfGZDf8s_Z9BI5kICjGGnvs45PawHYu6cqsTqftwZ_5DZFqEGKZfdP8Q6I2"; +const CLIENT_ID = Meteor.settings.public.patreon.clientId; Template.profile.onCreated(function(){ this.showApiKey = new ReactiveVar(false); + this.loadingPatreon = new ReactiveVar(false); }); Template.profile.helpers({ @@ -39,10 +40,17 @@ Template.profile.helpers({ if (!user) return; patreon = user.patreon; if (!patreon) return; - let tier = patreon.entitledCents || 0; - if (patreon.entitledCentsOverride > tier) tier = patreon.entitledCentsOverride; - return tier; - } + let entitledCents = patreon.entitledCents || 0; + if (Template.instance().loadingPatreon.get()){ + return "loading..." + } else if (patreon.entitledCentsOverride > entitledCents){ + return `$ ${(patreon.entitledCentsOverride / 100).toFixed(0)} (Overridden)"`; + } else if (patreon.entitledCents === undefined){ + return "?"; + } else { + return "$" + (patreon.entitledCents / 100).toFixed(0); + } + }, }); Template.profile.events({ @@ -70,4 +78,10 @@ Template.profile.events({ Meteor.call("generateMyApiKey"); instance.showApiKey.set(true); }, + "click .refreshPatreon": function(event, instance){ + instance.loadingPatreon.set(true); + Meteor.call("updateMyPatreonDetails", (error) => { + instance.loadingPatreon.set(false); + }); + }, }); diff --git a/app/server/patreon/patreon.js b/app/server/patreon/patreon.js index feb03927..71d77ad7 100644 --- a/app/server/patreon/patreon.js +++ b/app/server/patreon/patreon.js @@ -1,7 +1,9 @@ import request from 'request'; -const CLIENT_ID = "zv38izfGZDf8s_Z9BI5kICjGGnvs45PawHYu6cqsTqftwZ_5DZFqEGKZfdP8Q6I2"; +const CLIENT_ID = Meteor.settings.public.patreon.clientId; const CLIENT_SECRET = Meteor.settings.patreon.clientSecret; +const CREATOR_ACCESS_TOKEN = Meteor.settings.patreon.creatorAccessToken; +const CAMPAIGN_ID = Meteor.settings.public.patreon.campaignId; // Handle redirects from patreon Router.map(function () { @@ -27,21 +29,7 @@ Router.map(function () { writePatreonError(userId, error); return; } - getIdentity(token.access_token, Meteor.bindEnvironment((error, response, body) => { - if (error){ - writePatreonError(userId, error); - return; - } - try { - let identity = JSON.parse(body); - let entitledAmount = +identity.included[0].attributes - .currently_entitled_amount_cents; - //TODO also write the patreon userId - writeEntitledCents(userId, entitledAmount); - } catch(error) { - writePatreonError(userId, error); - } - })); + updateIdentity(token.access_token, userId); })); route.response.writeHead(302, { 'Location': Meteor.absoluteUrl() + "account", @@ -68,21 +56,6 @@ const requestToken = function(singleUseCode, callback){ }, callback); } -// Should return a new access token for the user -// callback is called with (error, response, body) -const refreshAccessToken = function(refreshToken, callback){ - request({ - method: "POST", - uri: "https://www.patreon.com/api/oauth2/token", - qs: { - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: CLIENT_ID, - client_secret: CLIENT_SECRET, - } - }, callback); -}; - const getIdentity = function(accessToken, callback){ request({ uri: "https://www.patreon.com/api/oauth2/v2/identity", @@ -96,40 +69,188 @@ const getIdentity = function(accessToken, callback){ }, callback); }; -const writePatreonToken = function(userId, {access_token, refresh_token}){ - console.log('Writing token ') - console.log({access_token, refresh_token}); +// Should return a new access token for the user +// callback is called with (error, response, body) +const refreshAccessToken = Meteor.wrapAsync(function(refreshToken, userId, callback){ + request({ + method: "POST", + uri: "https://www.patreon.com/api/oauth2/token", + qs: { + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + } + }, Meteor.bindEnvironment((error, response, body) => { + // Should return an access token, valid for 1 month, which needs to be + // stored and used to make requests on behalf of the user + if (error){ + callback(error) + return; + } + let token; + try { + token = JSON.parse(body); + writePatreonToken(userId, token); + callback(undefined, token.access_token); + } catch(error) { + callback(error); + } + })); +}); + +const updateIdentity = Meteor.wrapAsync(function(accessToken, userId, callback){ + getIdentity(accessToken, Meteor.bindEnvironment((error, response, body) => { + if (error){ + writePatreonError(userId, error); + return; + } + try { + let identity = JSON.parse(body); + let membership = identity.included[0]; + let entitledAmount = membership && membership.attributes + .currently_entitled_amount_cents || 0; + let patreonUserId = identity.data.id; + writeEntitledCentsAndId(userId, entitledAmount, patreonUserId); + if (callback) callback(); + } catch(error) { + writePatreonError(userId, error); + if(callback) callback(error); + } + })); +}); + +Meteor.methods({ + updateMyPatreonDetails(){ + const userId = this.userId; + if (!userId) throw new Meteor.Error("not-logged-in", "You must be logged in to update Patreon details"); + const user = Meteor.users.findOne(userId, {fields: {patreon: 1}}); + Meteor.users.update(userId, {$unset: {"patreon.entitledCents": 1}}); + if (!user.patreon || !user.patreon.accessToken){ + throw new Meteor.Error("no-patreon-access", "Patreon access token not found for this user"); + } + let accessToken = user.patreon.accessToken; + if (user.patreon.tokenExpiryDate < new Date()){ + // Token expired, refresh it before continuing + accessToken = refreshAccessToken(user.patreon.refreshToken, userId); + } + updateIdentity(accessToken, userId); + }, +}); + +const writePatreonToken = function(userId, { + access_token, refresh_token, expires_in +}){ + // The expiry date is now plus `expires_in` seconds + let expiryDate = new Date(); + expiryDate.setSeconds(expiryDate.getSeconds() + expires_in); + // Expire a day early so we don't accidentally miss it + expiryDate.setDate(expiryDate.getDate() - 1); + + // Write Meteor.users.update(userId, { $set: { "patreon.accessToken": access_token, "patreon.refreshToken": refresh_token, + "patreon.tokenExpiryDate": expiryDate, }, $unset: { "patreon.error": 1, }, }); - console.log(Meteor.users.findOne(userId).patreon); }; -const writeEntitledCents = function(userId, amount){ - console.log('Writing cents ') - console.log(arguments); +const writeEntitledCentsAndId = function(userId, amount, patreonUserId){ Meteor.users.update(userId, { $set: { "patreon.entitledCents": amount, + "patreon.userId": patreonUserId, }, $unset: { "patreon.error": 1, }, }); - console.log(Meteor.users.findOne(userId).patreon); }; const writePatreonError = function(userId, error){ - console.error(error); + console.error({patreonError: error}); Meteor.users.update(userId, { $set: { "patreon.error": error.toString(), }, }); } + + +const requestMembers = Meteor.wrapAsync(function(cursor, members, callback){ + request({ + uri: `https://www.patreon.com/api/oauth2/v2/campaigns/${CAMPAIGN_ID}/members`, + headers:{ + Authorization: "Bearer " + CREATOR_ACCESS_TOKEN, + }, + qs: { + "include": "user", + "fields[member]": "currently_entitled_amount_cents", + "page[cursor]": cursor, + } + }, (error, reponse, body) => { + if (error){ + callback(error); + return; + } + let json = JSON.parse(body); + if (json.errors) { + callback(json.errors); + return; + } + let newMembers = json.data.map(member => ({ + id: member.relationships.user.data.id, + entitledCents: member.attributes.currently_entitled_amount_cents, + })); + members.push(...newMembers); + let next = json.meta.pagination.cursors && json.meta.pagination.cursors.next; + if (next){ + callback(undefined, next); + } else { + callback(undefined); + } + }); +}); + +const updatePatreonMembersEntitledCents = function(){ + let next = ""; + let members = []; + do { + next = requestMembers(next, members); + } while (next) + members.forEach(({id, entitledCents}) => { + Meteor.users.update({ + "patreon.userId": id + }, {$set: { + "patreon.entitledCents":entitledCents, + }}); + }); + return members; +} + +// Method to run a manual update +Meteor.methods({ + updatePatreonMembersEntitledCents(){ + const user = Meteor.users.findOne(this.userId); + if (!user || !_.contains(user.roles, "admin")) throw new Meteor.Error( + "permission-error", "You need to be logged in as an admin to run this method" + ); + return updatePatreonMembersEntitledCents(); + }, +}); + +// Cron job to run the update automatically +Meteor.startup(() => { + SyncedCron.add({ + name: "updatePatreonMembersEntitledCents", + schedule: function(parser) { + return parser.text('every 4 hours'); + }, + job: updatePatreonMembersEntitledCents, + }); +})