Skip to content

Commit

Permalink
Active premium content middleware
Browse files Browse the repository at this point in the history
  • Loading branch information
Saliou Diallo committed Sep 14, 2022
1 parent a417f73 commit be769a7
Show file tree
Hide file tree
Showing 4 changed files with 227 additions and 95 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,10 @@ import {
errorResponseForbidden,
errorResponseBadRequest
} from '../../apiHelpers'
import { recoverWallet } from '../../apiSigning'
import { NextFunction, Request, Response } from 'express'
import { isPremiumContentMatch } from '../../premiumContent/helpers'
import { PremiumContentType } from '../../premiumContent/types'
import { getRegisteredDiscoveryNodes } from '../../utils/getRegisteredDiscoveryNodes'
import { PremiumContentAccessError } from '../../premiumContent/types'
import { checkAccess } from '../../premiumContent/helpers'
import type Logger from 'bunyan'
import { Redis } from 'ioredis'

/**
* Middleware to validate requests to get premium content.
Expand All @@ -31,70 +28,66 @@ export const premiumContentMiddleware = async (
next: NextFunction
) => {
try {
const {
signedDataFromDiscoveryNode,
signatureFromDiscoveryNode,
signedDataFromUser,
signatureFromUser
} = req.headers

if (
!signedDataFromDiscoveryNode ||
!signatureFromDiscoveryNode ||
!signedDataFromUser ||
!signatureFromUser
) {
const cid = req.params && req.params.CID
if (!cid) {
return sendResponse(
req,
res,
errorResponseBadRequest('Missing request headers.')
errorResponseBadRequest(`Invalid request, no CID provided.`)
)
}

// @ts-ignore
const premiumContentHeaders = req.headers['x-premium-content'] as string
const libs = req.app.get('audiusLibs')
const redis = req.app.get('redisClient')
const logger = (req as any).logger as Logger

const discoveryNodeWallet = recoverWallet(
signedDataFromDiscoveryNode,
signatureFromDiscoveryNode
)
const isRegisteredDN = await isRegisteredDiscoveryNode({
wallet: discoveryNodeWallet,
libs: req.app.get('audiusLibs'),
logger,
redis: req.app.get('redisClient')
})
if (!isRegisteredDN) {
return sendResponse(
req,
res,
errorResponseForbidden(
'Failed discovery node signature validation for premium content.'
)
)
}

const { premiumContentId, premiumContentType } = req.params

const userWallet = recoverWallet(signedDataFromUser, signatureFromUser)
const signedDataFromDiscoveryNodeObj = JSON.parse(
signedDataFromDiscoveryNode as string
// @ts-ignore
const { doesUserHaveAccess, trackId, isPremium, error } = await checkAccess(
{ cid, premiumContentHeaders, libs, logger, redis }
)
const isMatch = isPremiumContentMatch({
signedDataFromDiscoveryNode: signedDataFromDiscoveryNodeObj,
userWallet,
premiumContentId: parseInt(premiumContentId),
premiumContentType: premiumContentType as PremiumContentType,
logger
})
if (!isMatch) {
return sendResponse(
req,
res,
errorResponseForbidden('Failed match verification for premium content.')
)
if (doesUserHaveAccess) {
// Set premium content track id and 'premium-ness' so that next middleware or
// request handler does not need to make trips to the database to get this info.
// We need the info because if the content is premium, then we need to set
// the cache-control response header to no-cache so that nginx does not cache it.
// @ts-ignore
req.premiumContent = {
trackId,
isPremium
}
next()
return
} else {
switch (error) {
case PremiumContentAccessError.MISSING_HEADERS:
return sendResponse(
req,
res,
errorResponseForbidden(
'Missing request headers for premium content.'
)
)
case PremiumContentAccessError.INVALID_DISCOVERY_NODE:
return sendResponse(
req,
res,
errorResponseForbidden(
'Failed discovery node signature validation for premium content.'
)
)
case PremiumContentAccessError.FAILED_MATCH:
default:
return sendResponse(
req,
res,
errorResponseForbidden(
'Failed match verification for premium content.'
)
)
}
}

next()
} catch (e) {
const error = `Could not validate premium content access: ${
(e as Error).message
Expand All @@ -103,24 +96,3 @@ export const premiumContentMiddleware = async (
return sendResponse(req, res, errorResponseServerError(error))
}
}

async function isRegisteredDiscoveryNode({
wallet,
libs,
logger,
redis
}: {
wallet: string
libs: any
logger: Logger
redis: Redis
}) {
const allRegisteredDiscoveryNodes = await getRegisteredDiscoveryNodes({
libs,
logger,
redis
})
return allRegisteredDiscoveryNodes.some(
(node) => node.delegateOwnerWallet === wallet
)
}
155 changes: 146 additions & 9 deletions creator-node/src/premiumContent/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,144 @@
import { signatureHasExpired } from '../apiSigning'
import { PremiumContentSignatureData, PremiumContentType } from './types'
import { signatureHasExpired, recoverWallet } from '../apiSigning'
import {
PremiumContentAccessError,
PremiumContentSignatureData,
PremiumContentType
} from './types'
import { getRegisteredDiscoveryNodes } from '../utils/getRegisteredDiscoveryNodes'
import { Redis } from 'ioredis'
import models from '../models'
import type Logger from 'bunyan'

const PREMIUM_CONTENT_SIGNATURE_MAX_TTL_MS = 6 * 60 * 60 * 1000 // 6 hours

type CheckAccessArgs = {
cid: string
premiumContentHeaders: string
libs: any
logger: Logger
redis: Redis
}

export const checkAccess = async ({
cid,
premiumContentHeaders,
libs,
logger,
redis
}: CheckAccessArgs): Promise<
| { doesUserHaveAccess: true; trackId: null; isPremium: false }
| { doesUserHaveAccess: true; trackId: number; isPremium: boolean }
| { error: PremiumContentAccessError }
> => {
// Only apply premium content middleware logic if file is a premium track file
const { trackId, isPremium } = await isCIDForPremiumTrack(cid)
if (!isPremium) {
return { doesUserHaveAccess: true, trackId, isPremium }
}

if (!premiumContentHeaders) {
return { error: PremiumContentAccessError.MISSING_HEADERS }
}

const {
signedDataFromDiscoveryNode,
signatureFromDiscoveryNode,
signedDataFromUser,
signatureFromUser
} = JSON.parse(premiumContentHeaders)
if (
!signedDataFromDiscoveryNode ||
!signatureFromDiscoveryNode ||
!signedDataFromUser ||
!signatureFromUser
) {
return { error: PremiumContentAccessError.MISSING_HEADERS }
}

const discoveryNodeWallet = recoverWallet(
signedDataFromDiscoveryNode,
signatureFromDiscoveryNode
)
const isRegisteredDN = await isRegisteredDiscoveryNode({
wallet: discoveryNodeWallet,
libs,
logger,
redis
})
if (!isRegisteredDN) {
return { error: PremiumContentAccessError.INVALID_DISCOVERY_NODE }
}

const userWallet = await libs.web3Manager.verifySignature(
signedDataFromUser,
signatureFromUser
)
const isMatch = await isPremiumContentMatch({
signedDataFromDiscoveryNode,
userWallet,
premiumContentId: trackId as number,
premiumContentType: 'track',
logger
})
if (!isMatch) {
return { error: PremiumContentAccessError.FAILED_MATCH }
}

return { doesUserHaveAccess: true, trackId: trackId as number, isPremium }
}

async function isCIDForPremiumTrack(cid: string): Promise<
| {
trackId: null
isPremium: false
}
| {
trackId: number
isPremium: boolean
}
> {
// @ts-ignore
const cidFile = await models.File.findOne({
where: { multihash: cid }
})
if (!cidFile || (cidFile.type !== 'track' && cidFile.type !== 'copy320')) {
return { trackId: null, isPremium: false }
}

// @ts-ignore
const track = await models.Track.findOne({
where: { blockchainId: cidFile.trackBlockchainId }
})
if (!track) {
return { trackId: null, isPremium: false }
}

return {
trackId: parseInt(track.blockchainId),
isPremium: track.metadataJSON.is_premium
}
}

async function isRegisteredDiscoveryNode({
wallet,
libs,
logger,
redis
}: {
wallet: string
libs: any
logger: Logger
redis: Redis
}) {
const allRegisteredDiscoveryNodes = await getRegisteredDiscoveryNodes({
libs,
logger,
redis
})
return allRegisteredDiscoveryNodes.some(
(node) => node.delegateOwnerWallet === wallet
)
}

type PremiumContentMatchArgs = {
signedDataFromDiscoveryNode: PremiumContentSignatureData
Expand All @@ -9,25 +148,23 @@ type PremiumContentMatchArgs = {
logger?: any
}

const PREMIUM_CONTENT_SIGNATURE_MAX_TTL_MS = 6 * 60 * 60 * 1000 // 6 hours

/**
* Verify that DN-signed data timestamp is relatively recent.
* Verify that id and type (track/playlist) of content requested are same as those in the DN-signed data.
* Verify that wallet from recovered user signature is the same as that of wallet in the DN-signed data.
* If all these verifications are successful, then we have a match.
*/
export const isPremiumContentMatch = ({
async function isPremiumContentMatch({
signedDataFromDiscoveryNode,
userWallet,
premiumContentId,
premiumContentType,
logger = console
}: PremiumContentMatchArgs) => {
}: PremiumContentMatchArgs) {
const {
premiumContentId: signedPremiumContentId,
premiumContentType: signedPremiumContentType,
userWallet: signedUserWallet,
premium_content_id: signedPremiumContentId,
premium_content_type: signedPremiumContentType,
user_wallet: signedUserWallet,
timestamp: signedTimestamp
} = signedDataFromDiscoveryNode

Expand Down
12 changes: 9 additions & 3 deletions creator-node/src/premiumContent/types.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
export type PremiumContentType = 'track'

export type PremiumContentSignatureData = {
premiumContentId: number
premiumContentType: PremiumContentType
userWallet: string
premium_content_id: number
premium_content_type: PremiumContentType
user_wallet: string
timestamp: string
}

export enum PremiumContentAccessError {
MISSING_HEADERS = 'MISSING_HEADERS',
INVALID_DISCOVERY_NODE = 'INVALID_DISCOVERY_NODE',
FAILED_MATCH = 'FAILED_MATCH'
}

0 comments on commit be769a7

Please sign in to comment.