From 073578b90d178e8f4bd8d03d7bebb40543040d43 Mon Sep 17 00:00:00 2001 From: Thaum Rystra Date: Thu, 30 Apr 2020 22:38:27 +0200 Subject: [PATCH] Made all login Patreon only, limited some functionality to $5 patrons --- app/.meteor/packages | 1 + app/.meteor/versions | 2 + app/imports/server/publications/users.js | 7 +- app/imports/ui/StoryBook.vue | 100 ----------- app/imports/ui/layouts/Sidebar.vue | 113 +++++++----- app/imports/ui/pages/Account.vue | 118 ++++++++---- app/imports/ui/pages/Home.vue | 150 ++++++++++++---- app/imports/ui/pages/NotFound.vue | 14 +- app/imports/ui/pages/PatreonLevelTooLow.vue | 38 ++++ app/imports/ui/pages/SignIn.vue | 180 ++++++++++++------- app/imports/ui/router.js | 48 ++--- app/package.json | 3 +- app/packages/accounts-patreon/.gitignore | 1 + app/packages/accounts-patreon/package.js | 17 ++ app/packages/accounts-patreon/patreon.js | 26 +++ app/packages/patreon-oauth/README.md | 3 + app/packages/patreon-oauth/package.js | 18 ++ app/packages/patreon-oauth/patreon_client.js | 54 ++++++ app/packages/patreon-oauth/patreon_server.js | 75 ++++++++ 19 files changed, 654 insertions(+), 314 deletions(-) delete mode 100644 app/imports/ui/StoryBook.vue create mode 100644 app/imports/ui/pages/PatreonLevelTooLow.vue create mode 100644 app/packages/accounts-patreon/.gitignore create mode 100644 app/packages/accounts-patreon/package.js create mode 100644 app/packages/accounts-patreon/patreon.js create mode 100644 app/packages/patreon-oauth/README.md create mode 100644 app/packages/patreon-oauth/package.js create mode 100644 app/packages/patreon-oauth/patreon_client.js create mode 100644 app/packages/patreon-oauth/patreon_server.js diff --git a/app/.meteor/packages b/app/.meteor/packages index 0208062b..0dd00e08 100644 --- a/app/.meteor/packages +++ b/app/.meteor/packages @@ -47,3 +47,4 @@ static-html aldeed:collection2@3.0.0 aldeed:schema-index akryum:vue-component +accounts-patreon diff --git a/app/.meteor/versions b/app/.meteor/versions index fe231134..7205fac5 100644 --- a/app/.meteor/versions +++ b/app/.meteor/versions @@ -2,6 +2,7 @@ accounts-base@1.6.0 accounts-google@1.3.3 accounts-oauth@1.2.0 accounts-password@1.6.0 +accounts-patreon@0.1.0 accounts-ui@1.3.1 accounts-ui-unstyled@1.4.2 akryum:npm-check@0.1.2 @@ -88,6 +89,7 @@ oauth2@1.3.0 observe-sequence@1.0.16 ongoworks:speakingurl@9.0.0 ordered-dict@1.1.0 +patreon-oauth@0.1.0 percolate:migrations@0.9.8 percolate:synced-cron@1.3.2 promise@0.11.2 diff --git a/app/imports/server/publications/users.js b/app/imports/server/publications/users.js index cf50d698..06fe0f3e 100644 --- a/app/imports/server/publications/users.js +++ b/app/imports/server/publications/users.js @@ -1,15 +1,18 @@ import '/imports/api/users/Users.js'; -Meteor.publish("user", function(){ +Meteor.publish('user', function(){ return Meteor.users.find(this.userId, {fields: { roles: 1, username: 1, apiKey: 1, darkMode: 1, + 'services.patreon.id': 1, + 'services.patreon.entitledCents': 1, + 'services.patreon.entitledCentsOverride': 1, }}); }); -Meteor.publish("userPublicProfiles", function(ids){ +Meteor.publish('userPublicProfiles', function(ids){ if (!this.userId || !Array.isArray(ids)) return []; return Meteor.users.find({ _id: {$in: ids} diff --git a/app/imports/ui/StoryBook.vue b/app/imports/ui/StoryBook.vue deleted file mode 100644 index 934c5232..00000000 --- a/app/imports/ui/StoryBook.vue +++ /dev/null @@ -1,100 +0,0 @@ - - - - - diff --git a/app/imports/ui/layouts/Sidebar.vue b/app/imports/ui/layouts/Sidebar.vue index 95fb9eef..8eecfac7 100644 --- a/app/imports/ui/layouts/Sidebar.vue +++ b/app/imports/ui/layouts/Sidebar.vue @@ -1,59 +1,77 @@ diff --git a/app/imports/ui/pages/Home.vue b/app/imports/ui/pages/Home.vue index d2486fe7..2b2601b3 100644 --- a/app/imports/ui/pages/Home.vue +++ b/app/imports/ui/pages/Home.vue @@ -5,8 +5,16 @@
- - + +

DiceCloud - Free, Auditable, real-time character tracking for 5th edition

@@ -17,9 +25,22 @@
- - - money_off + + + + money_off +

Free, open source, community funded

@@ -28,8 +49,16 @@ and the source code is available on Github under a GPL license.

- - ballot + + + ballot +

Character sheets optimised for one ruleset

@@ -38,8 +67,16 @@ does: being a fully automated character tracker

- - scatter_plot + + + scatter_plot +

Inventory manager

@@ -51,9 +88,21 @@
-
- - +
+ + Sign up @@ -62,29 +111,34 @@

Check out the example characters

- + - - - Starter set archer - - + + + Starter set archer + + - - - Starter set wizard - - + + + Starter set wizard + + @@ -93,22 +147,48 @@

Get involved in the DiceCloud community

- - + + Reddit - + Discord - + Patreon - + Github @@ -119,7 +199,7 @@ diff --git a/app/imports/ui/pages/PatreonLevelTooLow.vue b/app/imports/ui/pages/PatreonLevelTooLow.vue new file mode 100644 index 00000000..4b34b502 --- /dev/null +++ b/app/imports/ui/pages/PatreonLevelTooLow.vue @@ -0,0 +1,38 @@ + + + diff --git a/app/imports/ui/pages/SignIn.vue b/app/imports/ui/pages/SignIn.vue index e50d804d..80022be4 100644 --- a/app/imports/ui/pages/SignIn.vue +++ b/app/imports/ui/pages/SignIn.vue @@ -1,74 +1,112 @@ diff --git a/app/imports/ui/router.js b/app/imports/ui/router.js index 62746a28..c5ea765e 100644 --- a/app/imports/ui/router.js +++ b/app/imports/ui/router.js @@ -1,5 +1,4 @@ import { RouterFactory, nativeScrollBehavior } from 'meteor/akryum:vue-router2'; -import Vue from 'vue'; // Components import Home from '/imports/ui/pages/Home.vue'; @@ -10,6 +9,7 @@ import SignIn from '/imports/ui/pages/SignIn.vue' ; import Register from '/imports/ui/pages/Register.vue' ; import Account from '/imports/ui/pages/Account.vue' ; import NotImplemented from '/imports/ui/pages/NotImplemented.vue'; +import PatreonLevelTooLow from '/imports/ui/pages/PatreonLevelTooLow.vue'; // Not found import NotFound from '/imports/ui/pages/NotFound.vue'; @@ -21,6 +21,22 @@ const routerFactory = new RouterFactory({ scrollBehavior: nativeScrollBehavior, }); +function ensurePatronTier(to, from, next){ + let user = Meteor.user(); + if (!user){ + next('/sign-in'); + return; + } + let entitledCents = user.services.patreon.entitledCents || 0; + let overrideCents = user.services.patreon.entitledCentsOverride || 0; + + if (entitledCents < 500 && overrideCents < 500){ + next('/patreon-level-too-low'); + } else { + next(); + } +} + RouterFactory.configure(factory => { factory.addRoutes([ { @@ -30,48 +46,36 @@ RouterFactory.configure(factory => { },{ path: '/characterList', component: CharacterList, - //component: NotImplemented, + beforeEnter: ensurePatronTier, },{ path: '/library', component: Library, + beforeEnter: ensurePatronTier, },{ path: '/character/:id/:urlName', component: CharacterSheetPage, - //component: NotImplemented, + beforeEnter: ensurePatronTier, },{ path: '/character/:id', component: CharacterSheetPage, - //component: NotImplemented, - + beforeEnter: ensurePatronTier, },{ path: '/sign-in', component: SignIn, - },{ + },/*{ path: '/register', component: Register, - },{ + },*/{ path: '/account', component: Account, },{ path: '/feedback', component: NotImplemented, + },{ + path: '/patreon-level-too-low', + component: PatreonLevelTooLow, }, ]); - // Storybook routes - if (Meteor.settings.public.showStorybook || Meteor.isDevelopment){ - let StoryBook = require('/imports/ui/StoryBook.vue').default; - factory.addRoutes([ - { - path: '/storybook/:component', - name: 'componentStory', - component: StoryBook, - },{ - path: '/storybook', - name: 'storybook', - component: StoryBook, - }, - ]); - } // Icon admin routes if (Meteor.isDevelopment){ let IconAdmin = require('/imports/ui/icons/IconAdmin.vue').default; diff --git a/app/package.json b/app/package.json index 86123429..9f4df2ec 100644 --- a/app/package.json +++ b/app/package.json @@ -82,7 +82,8 @@ "env": { "es6": true, "browser": true, - "node": true + "node": true, + "meteor": true }, "rules": { "quotes": [ diff --git a/app/packages/accounts-patreon/.gitignore b/app/packages/accounts-patreon/.gitignore new file mode 100644 index 00000000..677a6fc2 --- /dev/null +++ b/app/packages/accounts-patreon/.gitignore @@ -0,0 +1 @@ +.build* diff --git a/app/packages/accounts-patreon/package.js b/app/packages/accounts-patreon/package.js new file mode 100644 index 00000000..4a925642 --- /dev/null +++ b/app/packages/accounts-patreon/package.js @@ -0,0 +1,17 @@ +Package.describe({ + summary: 'Login service for Patreon accounts', + version: '0.1.0', +}); + +Package.onUse(api => { + api.use('ecmascript'); + api.use('accounts-base', ['client', 'server']); + // Export Accounts (etc) to packages using this one. + api.imply('accounts-base', ['client', 'server']); + + api.use('accounts-oauth', ['client', 'server']); + api.use('patreon-oauth'); + api.imply('patreon-oauth'); + + api.addFiles('patreon.js'); +}); diff --git a/app/packages/accounts-patreon/patreon.js b/app/packages/accounts-patreon/patreon.js new file mode 100644 index 00000000..017a2618 --- /dev/null +++ b/app/packages/accounts-patreon/patreon.js @@ -0,0 +1,26 @@ +Accounts.oauth.registerService('patreon'); +console.log('accounts-patreon'); + +if (Meteor.isClient) { + const loginWithPatreon = (options, callback) => { + // support a callback without options + if (! callback && typeof options === 'function') { + callback = options; + options = null; + } + + const credentialRequestCompleteCallback = Accounts.oauth.credentialRequestCompleteHandler(callback); + Patreon.requestCredential(options, credentialRequestCompleteCallback); + }; + Accounts.registerClientLoginFunction('patreon', loginWithPatreon); + Meteor.loginWithPatreon = + (...args) => Accounts.applyLoginFunction('patreon', args); +} else { + Accounts.addAutopublishFields({ + // publish all fields including access token, which can legitimately + // be used from the client (if transmitted over ssl or on + // localhost). http://www.meetup.com/meetup_api/auth/#oauth2implicit + forLoggedInUser: ['services.patreon'], + forOtherUsers: ['services.patreon.id'] + }); +} diff --git a/app/packages/patreon-oauth/README.md b/app/packages/patreon-oauth/README.md new file mode 100644 index 00000000..ee15345f --- /dev/null +++ b/app/packages/patreon-oauth/README.md @@ -0,0 +1,3 @@ +# patreon-oauth + +An implementation of the Patreon OAuth flow. See the [Meteor Guide](https://guide.meteor.com/accounts.html) for more details. diff --git a/app/packages/patreon-oauth/package.js b/app/packages/patreon-oauth/package.js new file mode 100644 index 00000000..2ed1a5f7 --- /dev/null +++ b/app/packages/patreon-oauth/package.js @@ -0,0 +1,18 @@ +Package.describe({ + summary: 'Patreon OAuth flow', + version: '0.1.0' +}); + +Package.onUse(api => { + api.use('ecmascript'); + api.use('oauth2', ['client', 'server']); + api.use('oauth', ['client', 'server']); + api.use('http', 'server'); + api.use('random', 'client'); + api.use('service-configuration', ['client', 'server']); + + api.addFiles('patreon_server.js', 'server'); + api.addFiles('patreon_client.js', 'client'); + + api.export('Patreon'); +}); diff --git a/app/packages/patreon-oauth/patreon_client.js b/app/packages/patreon-oauth/patreon_client.js new file mode 100644 index 00000000..be161908 --- /dev/null +++ b/app/packages/patreon-oauth/patreon_client.js @@ -0,0 +1,54 @@ +Patreon = {}; +console.log('patreon-oauth'); + +// Request Patreon credentials for the user +// @param options {optional} +// @param credentialRequestCompleteCallback {Function} Callback function to call on +// completion. Takes one argument, credentialToken on success, or Error on +// error. +Patreon.requestCredential = (options, credentialRequestCompleteCallback) => { + // support both (options, callback) and (callback). + if (!credentialRequestCompleteCallback && typeof options === 'function') { + credentialRequestCompleteCallback = options; + options = {}; + } + + const config = ServiceConfiguration.configurations.findOne({service: 'patreon'}); + if (!config) { + credentialRequestCompleteCallback && credentialRequestCompleteCallback( + new ServiceConfiguration.ConfigError()); + return; + } + + // For some reason, meetup converts underscores to spaces in the state + // parameter when redirecting back to the client, so we use + // `Random.id()` here (alphanumerics) instead of `Random.secret()` + // (base 64 characters). + const credentialToken = Random.id(); + + const scope = (options && options.requestPermissions) || [ + 'identity', + 'identity[email]', + ]; + const flatScope = scope.map(encodeURIComponent).join(' '); + //const flatScope = encodeURIComponent(scope.join(',')); + + console.log({flatScope}) + const loginStyle = OAuth._loginStyle('patreon', config, options); + + const loginUrl = + 'https://www.patreon.com/oauth2/authorize' + + `?client_id=${config.clientId}` + + '&response_type=code' + + (flatScope ? `&scope=${flatScope}` : '') + + `&redirect_uri=${OAuth._redirectUri('patreon', config)}` + + `&state=${OAuth._stateParam(loginStyle, credentialToken, options && options.redirectUrl)}`; + + OAuth.launchLogin({ + loginService: 'patreon', + loginStyle, + loginUrl, + credentialRequestCompleteCallback, + credentialToken, + }); +}; diff --git a/app/packages/patreon-oauth/patreon_server.js b/app/packages/patreon-oauth/patreon_server.js new file mode 100644 index 00000000..8086acf3 --- /dev/null +++ b/app/packages/patreon-oauth/patreon_server.js @@ -0,0 +1,75 @@ +Patreon = {}; + +OAuth.registerService('patreon', 2, null, query => { + const response = getAccessToken(query); + const accessToken = response.access_token; + const refreshToken = response.refresh_token; + const scope = response.scope; + const expiresAt = (+new Date) + (1000 * response.expires_in); + const identity = getIdentity(accessToken); + let serviceData = { + id : identity.data.id, + email: identity.data.attributes.email, + entitledCents: identity.included[0] && + identity.included[0].attributes.currently_entitled_amount_cents || 0, + accessToken, + refreshToken, + scope, + expiresAt, + }; + return { serviceData }; +}); + +const getAccessToken = query => { + const config = ServiceConfiguration.configurations.findOne({service: 'patreon'}); + if (!config) + throw new ServiceConfiguration.ConfigError(); + + let response; + try { + response = HTTP.post( + 'https://www.patreon.com/api/oauth2/token', {headers: {Accept: 'application/json'}, params: { + code: query.code, + client_id: config.clientId, + client_secret: config.secret, + grant_type: 'authorization_code', + redirect_uri: OAuth._redirectUri('patreon', config), + }}); + } catch (err) { + throw Object.assign( + new Error(`Failed to complete OAuth handshake with Patreon. ${err.message}`), + { response: err.response } + ); + } + + if (response.data.error) { // if the http response was a json object with an error attribute + throw new Error(`Failed to complete OAuth handshake with Patreon. ${response.data.error}`); + } else { + return response.data; + } +}; + +const getIdentity = accessToken => { + try { + const response = HTTP.get( + 'https://www.patreon.com/api/oauth2/v2/identity?' + + 'fields%5Buser%5D=email&' + + 'fields%5Bmember%5D=currently_entitled_amount_cents&' + + 'include=memberships', + { + headers: {authorization: `Bearer ${accessToken}`}, + } + ); + let data = JSON.parse(response.content); + return data; + } catch (err) { + throw Object.assign( + new Error(`Failed to fetch identity from Patreon. ${err.message}`), + { response: err.response } + ); + } +}; + + +Patreon.retrieveCredential = (credentialToken, credentialSecret) => + OAuth.retrieveCredential(credentialToken, credentialSecret);