-
Notifications
You must be signed in to change notification settings - Fork 16
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Port from cloudflare workers to nodejs+mongo
Signed-off-by: Dani Llewellyn <[email protected]>
- Loading branch information
0 parents
commit cd8b421
Showing
10 changed files
with
2,649 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
node_modules | ||
.envrc |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
} |
Oops, something went wrong.