streetmix/streetmix

View on GitHub
app/resources/services/integrations/patreon.js

Summary

Maintainability
B
6 hrs
Test Coverage
import crypto from 'node:crypto'
import passport from 'passport'
import { Strategy as PatreonStrategy } from 'passport-patreon'
import logger from '../../../lib/logger.js'
import appURL from '../../../lib/url.js'
import models from '../../../db/models/index.js'
import { findUser, addUserConnection } from './helpers.js'

const { User } = models

/*
our use case makes this a little complicated,
we basically have three user instances to check:

 * Auth0 data on a user who is authenticated (aka 'user)
 * Data on this user that we store in our database (aka 'databaseUser')
 * Data from a user's third party account (aka profile)

Our existing code handles login using the auth0 service,
which if valid we store a JWT.

We pass this to the initial get route,
which passes along the account id/name (and errors if no one is signed in)

We then pass this to our passport strategy, which grabs third party data and
looks up the user in _our_ database, which then gets handled by the callback function
an subsequently links all this info together
*/

const initPatreon = () => {
  passport.use(
    new PatreonStrategy(
      {
        clientID: process.env.PATREON_CLIENT_ID,
        clientSecret: process.env.PATREON_CLIENT_SECRET,
        callbackURL: `${appURL.origin}/services/integrations/patreon/callback`,
        scope: 'users pledges-to-me my-campaign',
        passReqToCallback: true
      },
      async function (req, accessToken, refreshToken, profile, done) {
        /*
        this is what passport calls the 'verify callback'
        our case is a little different since we already have a logged in user
        not sure if this is the best place to handle checking the DB or not but it works
         */
        const databaseUser = await findUser(req.query.state)
        // passing the profile data along the request, probably another way to do this
        req.profile = profile
        return done(null, databaseUser)
      }
    )
  )

  // these aren't used yet, but would be if we start using persistent user sessions
  passport.serializeUser(function (user, done) {
    done(null, user)
  })

  passport.deserializeUser(function (id, done) {
    const user = async () => {
      await User.findByPk(id)
    }
    done(null, user)
  })
}

// Only initialize Patreon auth strategy if the env vars are set.
if (process.env.PATREON_CLIENT_ID && process.env.PATREON_CLIENT_SECRET) {
  initPatreon()
}

export function get (req, res, next) {
  if (!process.env.PATREON_CLIENT_ID || !process.env.PATREON_CLIENT_SECRET) {
    res
      .status(500)
      .json({ status: 500, msg: 'Patreon integration unavailable.' })
    return
  }

  /*
  at this point,the request has user info from auth0 that we passed to this route
  auth0 nickname == user.id in our internal db that we'll need later for lookup

  this might not be available if the user isn't signed in
    we could add error handiling for this, but also they _should_ only ever be
    getting here from a button that you only see when you're signed in..
  */
  passport.authorize('patreon', {
    state: req.auth.sub,
    failureRedirect: '/error'
  })(req, res, next)
}

export function callback (req, res, next) {
  if (!process.env.PATREON_CLIENT_ID || !process.env.PATREON_CLIENT_SECRET) {
    res
      .status(500)
      .json({ status: 500, msg: 'Patreon integration unavailable.' })
    return
  }

  passport.authorize('patreon', {
    failureRedirect: '/error'
  })(req, res, next)
}

/**
 * connects the third party profile with the database user record
 * pass third party profile data here, construct an object to save to user DB
 */
export async function connectUser (req, res) {
  // in passport, using 'authorize' attaches user data to 'account'
  // instead of overriding the user session data
  const account = req.account
  const profile = req.profile

  try {
    await addUserConnection(account, profile)
    res.redirect('/')
  } catch (err) {
    logger.error(err)
    res.redirect('/error')
  }
}

export function webhook (req, res, next) {
  // Check for the existence of headers specified by Patreon Webhooks docs
  // https://docs.patreon.com/#webhooks
  // While the docs specify headers with capitalization, the actual headers
  // we recieve are lowercase.
  if (
    typeof req.headers['x-patreon-event'] === 'undefined' ||
    typeof req.headers['x-patreon-signature'] === 'undefined'
  ) {
    res.status(403).end()
    return
  }

  // Verify that the sender is an authorized Patreon service. The message body
  // is HMAC signed with MD5 using the webhook's secret key, which we obtain
  // from Patreon and store in an environment variable. If the HEX digest
  // matches, then we know the message is valid.
  // If the webhook secret is not provided, we send a "Not implemented" code.
  if (typeof process.env.PATREON_WEBHOOK_SECRET === 'undefined') {
    res.status(501).end()
    return
  }

  const hmac = crypto.createHmac('md5', process.env.PATREON_WEBHOOK_SECRET)
  hmac.update(JSON.stringify(req.body))
  const digest = hmac.digest('hex')

  if (digest !== req.headers['x-patreon-signature']) {
    res.status(403).end()
    return
  }

  console.log(JSON.stringify(req.body))
  console.log(req.headers['x-patreon-event'])
  console.log(req.headers['x-patreon-signature'])

  res.status(204).end()
}