Skip to content

Commit

Permalink
feat(websub): Add websub controller and helper methods (#8014)
Browse files Browse the repository at this point in the history
  • Loading branch information
searchableguy committed Jan 14, 2022
1 parent 28d67ec commit a30e3e7
Show file tree
Hide file tree
Showing 3 changed files with 220 additions and 1 deletion.
4 changes: 3 additions & 1 deletion locksmith/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
"express-winston": "4.2.0",
"graphql": "15.8.0",
"graphql-tag": "2.12.6",
"html-template-tag": "^4.0.0",
"isomorphic-fetch": "3.0.0",
"lodash.isequal": "4.5.0",
"multer": "1.4.4",
Expand All @@ -82,7 +83,8 @@
"unlock-abi-1-1": "1.1.1",
"winston": "3.3.3",
"winston-sentry-log": "1.0.24",
"yargs": "17.3.1"
"yargs": "17.3.1",
"zod": "^3.11.6"
},
"devDependencies": {
"@types/cors": "2.8.12",
Expand Down
192 changes: 192 additions & 0 deletions locksmith/src/controllers/hookController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import { Response, Request } from 'express'
import { networks } from '@unlock-protocol/networks'
import * as z from 'zod'
import fetch from 'cross-fetch'
import crypto from 'crypto'
import html from 'html-template-tag'
import logger from '../logger'
import { Hook } from '../models'

const Hub = z.object({
topic: z.string().url(),
callback: z.string().url(),
mode: z.enum(['subscribe', 'unsubscribe']),
lease_seconds: z.number().optional(),
secret: z.string().optional(),
})

const EXPIRATION_SECONDS_LIMIT = 86400 * 90

export function getExpiration(leaseSeconds?: number) {
const limit = leaseSeconds ?? 864000
if (limit > EXPIRATION_SECONDS_LIMIT) {
throw new Error("Lease seconds can't be greater than 90 days")
}
return new Date(Date.now() + limit * 1000)
}

export async function subscribe(
hub: z.infer<typeof Hub>,
params: Record<string, string>
) {
try {
const hook = await Hook.findOne({
where: {
topic: hub.topic,
callback: hub.callback,
},
})

if (!hook) {
await validHubIntent(hub)

const expiration = getExpiration(hub.lease_seconds)

const createdHook = await Hook.create({
expiration,
topic: hub.topic,
callback: hub.callback,
mode: hub.mode,
secret: hub.secret,
network: params.network,
lock: params.lock,
})

return createdHook
}

if (hook.mode !== hub.mode) {
hook.mode = hub.mode
}

if (hub.lease_seconds) {
hook.expiration = getExpiration(hub.lease_seconds)
}

if (hub.secret) {
hook.secret = hub.secret
}

await validHubIntent(hub)

await hook.save()
return hook
} catch (error) {
logger.error(error.message)
return error
}
}

// Check if the subscriber request is valid or not. This will post a challenge to subscriber to confirm the intent of the request.
export async function validHubIntent(hub: z.infer<typeof Hub>) {
const challenge = crypto.randomBytes(20).toString('hex')
const result = await fetch(hub.callback, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
hub: {
...hub,
challenge,
},
}),
})

const json = await result.json()

if (!result.ok) {
throw new Error("Subscriber didn't confirm intent.")
}

if (json.challenge !== challenge) {
throw new Error('Challenge did not match')
}

if (json.topic !== hub.topic) {
throw new Error('Topic did not match')
}

if (json.callback !== hub.callback) {
throw new Error('Callback did not match')
}

return hub
}

export function getSupportedNetwork(network: string) {
if (!networks[network]) {
logger.error(`Unsupported network: ${network}`)
return
}
return networks[network]
}

export async function subscriptionHandler(req: Request, res: Response) {
try {
// We check the hub schema here to make sure the subscriber is sending us the correct data.
const hub = await Hub.parseAsync(req.body.hub)
// We check the network here to make sure the subscriber is sending to the right network endpoint.
const network = getSupportedNetwork(req.params.network)
if (!network) {
return res.status(400).send('Unsupported network')
}
// We send the accepted request to the subscriber and then validate the intent of the subscriber as well as persist the subscription.
res.status(202).send('Accepted')
return subscribe(hub, req.params)
} catch (error) {
logger.error(error.message)
// We respond with the error if we cannot subscribe or there is an error in the subscriber request.
return res.status(500).send({
hub: {
mode: req.body.hub.mode,
topic: req.body.hub.topic,
reason: error.message,
},
})
}
}

export function publisherHandler(req: Request, res: Response) {
try {
const network = getSupportedNetwork(req.params.network)
if (!network) {
return res.status(400).send('Unsupported network')
}

const url = new URL(req.originalUrl, `${req.protocol}://${req.hostname}`)
const links = [
{
rel: 'self',
href: url.toString(),
},
{
rel: 'hub',
href: url.toString(),
},
]
res.setHeader(
'Link',
links.map((item) => `<${item.href}>; rel="${item.rel}"`)
)

const htmlResponse = `
<!DOCTYPE html>
<html>
<head>
${links
.map((item) => html`<link rel="${item.rel}" href="${item.href}" />`)
.join('')}
</head>
<body>
To subscribe to our topic, make a POST request to this endpoint with the valid hub payload.
The link to hub is also included in the head and headers.
</body>
</html>
`
return res.status(200).send(htmlResponse)
} catch (error) {
logger.error(error.message)
return res.status(500).send(error.message)
}
}
25 changes: 25 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -12526,6 +12526,7 @@ __metadata:
express-winston: 4.2.0
graphql: 15.8.0
graphql-tag: 2.12.6
html-template-tag: ^4.0.0
isomorphic-fetch: 3.0.0
jest: 27.4.5
lodash.isequal: 4.5.0
Expand Down Expand Up @@ -12557,6 +12558,7 @@ __metadata:
winston: 3.3.3
winston-sentry-log: 1.0.24
yargs: 17.3.1
zod: ^3.11.6
languageName: unknown
linkType: soft

Expand Down Expand Up @@ -27358,6 +27360,13 @@ fsevents@~2.1.1:
languageName: node
linkType: hard

"html-es6cape@npm:^2.0.0":
version: 2.0.0
resolution: "html-es6cape@npm:2.0.0"
checksum: c47b0fff25db186732374e4d8ac9bf12c54058107a15ba8feb5c0bcb6b07575f551f2e02ee3f57078235475641b084e0bce586aae93b3fa7e7fdd51b392ce6a2
languageName: node
linkType: hard

"html-escaper@npm:^2.0.0":
version: 2.0.2
resolution: "html-escaper@npm:2.0.2"
Expand Down Expand Up @@ -27406,6 +27415,15 @@ fsevents@~2.1.1:
languageName: node
linkType: hard

"html-template-tag@npm:^4.0.0":
version: 4.0.0
resolution: "html-template-tag@npm:4.0.0"
dependencies:
html-es6cape: ^2.0.0
checksum: 643bbfeb3714cb845db8a17b84404288b778b8d767fdd25707859188d6f5e63dbcca89d6ab8e1e304d29682f3793bf5f5ddcf80c1f57d4df6ef8e13a40b42718
languageName: node
linkType: hard

"html-void-elements@npm:^1.0.0":
version: 1.0.5
resolution: "html-void-elements@npm:1.0.5"
Expand Down Expand Up @@ -49640,6 +49658,13 @@ typescript@^3.8.3:
languageName: node
linkType: hard

"zod@npm:^3.11.6":
version: 3.11.6
resolution: "zod@npm:3.11.6"
checksum: 044ac416450f179a0c88240f27849d2886c777cebade42df10e5f04125b0265cec82d9bd741a7dcb11796b2ea88b32c86be7d36932a4bed6af57002560359db1
languageName: node
linkType: hard

"zwitch@npm:^1.0.0":
version: 1.0.5
resolution: "zwitch@npm:1.0.5"
Expand Down

0 comments on commit a30e3e7

Please sign in to comment.