Skip to content

Commit

Permalink
Port from cloudflare workers to nodejs+mongo
Browse files Browse the repository at this point in the history
Signed-off-by: Dani Llewellyn <[email protected]>
  • Loading branch information
lucyllewy committed Sep 6, 2022
0 parents commit cd8b421
Show file tree
Hide file tree
Showing 10 changed files with 2,649 additions and 0 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules
.envrc
165 changes: 165 additions & 0 deletions api/mastodon.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import fetch from 'node-fetch'

export const mastodonHostCookieName = 'mastodonHost',
mastodonTokenCookieName = 'mastodonToken',
scopes = 'read:accounts' // read:follows write:follows

/**
* @param {string} mastodonDomain
* @returns Promise<{_id: string, client_id: string, client_secret: string}>
*/
function getMastodonApp(mastodonDomain) {
return this.mongo.db.collection('mastodon_apps').findOne({_id: mastodonDomain})
}

/**
* @param {string} mastodonHost
* @param {string} mastodonDomain
* @param {string} redirectUri
* @returns Promise<string>
*/
async function createMastodonApp(mastodonHost, mastodonDomain, redirectUri) {
const response = await fetch(`${mastodonHost}/api/v1/apps`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'client_name=twitodon' +
`&redirect_uris=${encodeURIComponent(redirectUri)}` +
`&scopes=${encodeURIComponent(scopes)}`
})

if (response.status !== 200) {
throw new Error('Mastodon API Error')
}

const {client_id, client_secret} = await response.json()

await this.mongo.db.collection('mastodon_apps').insertOne({
_id: mastodonDomain,
client_id,
client_secret,
})

return client_id
}

/**
* @param {string} mastodonHost
* @param {string} mastodonDomain
* @param {string} redirectUri
* @returns Promise<string>
*/
async function getOrCreateMastodonApp(mastodonHost, mastodonDomain, redirectUri) {
const app = await getMastodonApp.call(this, mastodonDomain)
if (app) {
return app.client_id
}

return createMastodonApp.call(this, mastodonHost, mastodonDomain, redirectUri)
}

/**
* @param {import('fastify').FastifyRequest} request
* @param {import('fastify').FastifyReply} reply
* @returns void
*/
export async function mastodonLoginUrl(request, reply) {
const mastodonHost = new URL(request.query.mastodonHost)
const mastodonDomain = (new URL(mastodonHost)).hostname
const redirectUri = new URL(`${request.protocol}://${request.hostname}/mastodonAuth`)

const client_id = await getOrCreateMastodonApp.call(this, mastodonHost.href, mastodonDomain, redirectUri.href)

reply
.setCookie(mastodonHostCookieName, mastodonHost.href, {
maxAge: 3600,
signed: true,
sameSite: 'lax',
})
.send({
mastodonLoginUrl: `${mastodonHost.href}/oauth/authorize?response_type=code&client_id=${client_id}&redirect_uri=${encodeURIComponent(redirectUri.href)}&scope=${scopes}`
})
}

/**
* @param {import('fastify').FastifyRequest} request
* @param {import('fastify').FastifyReply} reply
* @returns void
*/
export async function mastodonAuth(request, reply) {
const mastodonHost = request.unsignCookie(request.cookies[mastodonHostCookieName])
if (!mastodonHost.valid) {
throw new Error('No Mastodon Hostname cookie')
}

const mastodonDomain = (new URL(mastodonHost.value)).hostname
const redirectUri = new URL(`${request.protocol}://${request.hostname}/mastodonAuth`)

const {client_id, client_secret} = await getMastodonApp.call(this, mastodonDomain)

if (!client_id || !client_secret) {
throw new Error('Where are my credentials?!')
}

const body = `code=${encodeURIComponent(request.query.code)}` +
`&grant_type=${encodeURIComponent('authorization_code')}` +
`&client_id=${encodeURIComponent(client_id)}` +
`&client_secret=${encodeURIComponent(client_secret)}` +
`&redirect_uri=${encodeURIComponent(redirectUri.href)}` +
`&scope=${encodeURIComponent(scopes)}`

const oauthData = await fetch(`${mastodonHost.value}/oauth/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body,
})

if (oauthData.status !== 200) {
return reply.status(oauthData.status).clearCookie(mastodonTokenCookieName).send()
}

const json = await oauthData.json()

reply
.setCookie(mastodonTokenCookieName, json.access_token, {
maxAge: 3600,
signed: true,
sameSite: 'lax',
})
.redirect('/')
}

export async function meHandler(host, token) {
const url = `${host}/api/v1/accounts/verify_credentials`
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
},
})
if (response.status !== 200) {
throw new Error('Mastodon API error')
}
return response.json()
}

/**
* @param {import('fastify').FastifyRequest} request
* @param {import('fastify').FastifyReply} reply
* @returns void
*/
export async function mastodonMe(request, reply) {
const tokenCookie = request.unsignCookie(request.cookies[mastodonTokenCookieName])
const hostCookie = request.unsignCookie(request.cookies[mastodonHostCookieName])
if (!tokenCookie.valid) {
throw new Error('No Mastodon Authorization Token cookie')
}
if (!hostCookie.valid) {
throw new Error('No Mastodon Hostname cookie')
}

const {username} = await meHandler(hostCookie.value, tokenCookie.value)
reply.send(username)
}
139 changes: 139 additions & 0 deletions api/twitter.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import fetch from 'node-fetch'
import { randomBytes } from 'crypto'

export const twitterTokenCookieName = 'twitterToken'

const client_id = process.env.TWITTER_CLIENT_ID

/**
* @param {import('fastify').FastifyRequest} request
* @param {import('fastify').FastifyReply} reply
* @returns void
*/
export async function twitterLoginUrl(request, reply) {
const id = randomBytes(32).toString('hex')
const challenge = randomBytes(32).toString('hex')
const redirectUri = new URL(`${request.protocol}://${request.hostname}/twitterAuth`)

await this.mongo.db.collection('twitter_user_tokens').insertOne({
_id: id,
challenge,
CreatedDate: new Date(),
})

reply.send({
twitterLoginUrl: `https://twitter.com/i/oauth2/authorize?response_type=code&client_id=${client_id}&redirect_uri=${encodeURIComponent(redirectUri.href)}&scope=tweet.read%20users.read%20follows.read&state=${id}&code_challenge=${challenge}&code_challenge_method=plain`
})
}

/**
* @param {import('fastify').FastifyRequest} request
* @param {import('fastify').FastifyReply} reply
* @returns void
*/
export async function twitterAuth(request, reply) {
const id = request.query.state
const {challenge} = await this.mongo.db.collection('twitter_user_tokens').findOne({ _id: id })
const redirectUri = new URL(`${request.protocol}://${request.hostname}/twitterAuth`)

const body = `code=${encodeURIComponent(request.query.code)}` +
'&grant_type=authorization_code' +
`&client_id=${encodeURIComponent(client_id)}` +
`&redirect_uri=${encodeURIComponent(redirectUri.href)}` +
`&code_verifier=${encodeURIComponent(challenge)}`

const oauthData = await fetch('https://api.twitter.com/2/oauth2/token', {
method: 'POST',
body,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
})

const json = await oauthData.json()

if (oauthData.status !== 200) {
return reply.send(json)
return reply.code(oauthData.status).clearCookie(twitterTokenCookieName).send()
}

if (!'expires_in' in json || !json.expires_in || json.expires_in <= 0) {
json.expires_in = 3600
}
json.expires_in = Math.min(json.expires_in, 3600)

reply
.setCookie(twitterTokenCookieName, json.access_token, {
maxAge: json.expires_in,
signed: true,
sameSite: 'lax',
})
.redirect('/')
}

export async function meHandler(token) {
const data = await fetch('https://api.twitter.com/2/users/me', {
headers: {
'Authorization': `Bearer ${token}`,
},
})
if (data.status !== 200) {
throw new Error('Twitter API Error')
}
return data.json()
}

/**
* @param {import('fastify').FastifyRequest} request
* @param {import('fastify').FastifyReply} reply
* @returns void
*/
export async function twitterMe(request, reply) {
const token = request.unsignCookie(request.cookies[twitterTokenCookieName])
if (!token.valid) {
throw new Error('No Twitter Authorization Token cookie')
}
reply.send((await meHandler(token.value)).data.username)
}

/**
* @param {import('fastify').FastifyRequest} request
* @param {import('fastify').FastifyReply} reply
* @returns void
*/
export async function followingOnTwitter(request, reply) {
const token = request.unsignCookie(request.cookies[twitterTokenCookieName])
if (!token.valid) {
throw new Error('No Twitter Authorization Token cookie')
}

const { data: { id: userId } } = await meHandler(token.value)

let nextToken = '';
const users = []
while (true) {
const response = await fetch(
`https://api.twitter.com/2/users/${userId}/following?max_results=1000${nextToken}`,
{ headers: { Authorization: `Bearer ${token.value}` } }
)
if (!response.ok) {
break
}

const json = await response.json()
if (!(json && json.meta && json.meta.result_count && json.meta.result_count > 0)) {
break
}

if (json.data) {
users.push(json.data)
}

if (!json.meta.next_token) {
break
}
nextToken = `&pagination_token=${json.meta.next_token}`
}

reply.send(users.flat(1))
}
60 changes: 60 additions & 0 deletions api/user-matching.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { meHandler as twitterMe, twitterTokenCookieName } from "./twitter.mjs"
import { meHandler as mastodonMe, mastodonTokenCookieName, mastodonHostCookieName } from "./mastodon.mjs"

/**
* @param {import('fastify').FastifyRequest} request
* @param {import('fastify').FastifyReply} reply
* @returns void
*/
export async function addOrUpdateTwitterToMastodonMapping(request, reply) {
const twitterToken = request.unsignCookie(request.cookies[twitterTokenCookieName])
const mastodonToken = request.unsignCookie(request.cookies[mastodonTokenCookieName])
const mastodonHost = request.unsignCookie(request.cookies[mastodonHostCookieName])
if (!twitterToken.valid) {
throw new Error('No Twitter Authorization Token cookie')
}
if (!mastodonToken.valid) {
throw new Error('No Mastodon Authorization Token cookie')
}
if (!mastodonHost.valid) {
throw new Error('No Mastodon Hostname cookie')
}

const { data: { id: twitter_id } } = await twitterMe(twitterToken.value)
const { username: mastodon_id } = await mastodonMe(mastodonHost.value, mastodonToken.value)

await this.mongo.db.collection('twitter_to_mastodon_usermap').updateOne({
_id: twitter_id,
}, {
$set: {
_id: twitter_id,
mastodon_id: `${mastodon_id}@${new URL(mastodonHost.value).hostname}`,
},
}, {
upsert: true,
})

reply.send()
}

/**
* @param {import('fastify').FastifyRequest} request
* @param {import('fastify').FastifyReply} reply
* @returns void
*/
export async function matchTwitterUserToMastodon(request, reply) {
const mastodonToken = request.unsignCookie(request.cookies[mastodonTokenCookieName])
const mastodonHost = request.unsignCookie(request.cookies[mastodonHostCookieName])
if (!mastodonToken.valid) {
throw new Error('No Mastodon Authorization Token cookie')
}
if (!mastodonHost.valid) {
throw new Error('No Mastodon Hostname cookie')
}

const result = await this.mongo.db.collection('twitter_to_mastodon_usermap').findOne({ _id: request.body })
if (result) {
reply.send(result.mastodon_id)
}
reply.status(404).send()
}
Loading

0 comments on commit cd8b421

Please sign in to comment.