diff --git a/server/index.ts b/server/index.ts index 6e8a6e9f3..7bd8b78bd 100644 --- a/server/index.ts +++ b/server/index.ts @@ -41,7 +41,7 @@ import blogRoute from './routes/blog.js'; import calendarRoute from './routes/calendar.js'; import translationsRoute from './routes/translations.ts'; import subscriptionRoute from './routes/subscription.js'; -import discord from './routes/discord.js'; +import discord from './routes/discord.ts'; const MemoryStore = memorystore(session); diff --git a/server/routes/discord.js b/server/routes/discord.js deleted file mode 100644 index 535c56236..000000000 --- a/server/routes/discord.js +++ /dev/null @@ -1,164 +0,0 @@ -import { Router } from 'express'; -import { handleErrorAsync } from '../../src/helpers.ts'; -import SQL from 'sql-template-strings'; -import { normalise } from './user.js'; - -const configs = new Map() - .set('DiscordToken', process.env.DISCORD_TOKEN) - .set('DiscordClientId', process.env.DISCORD_CLIENT_ID) - .set('DiscordClientSecret', process.env.DISCORD_CLIENT_SECRET) - .set('DiscordRedirectUri', process.env.DISCORD_REDIRECT_URI); - -const store = new Map(); - -const BASE_DISCORD_URI = 'https://discord.com/api/v10'; - -const OAUTH_URL = Object.assign(new URL('/oauth2/authorize', BASE_DISCORD_URI), { - search: new URLSearchParams({ - client_id: configs.get('DiscordClientId'), - redirect_uri: configs.get('DiscordRedirectUri'), - response_type: 'code', - scope: [ - 'role_connections.write', - 'identify', - 'email', - ].join(' '), - prompt: 'consent', - }).toString(), -}).toString(); - -class TokenSet { - constructor(data) { - this.access_token = data.access_token; - this.refresh_token = data.refresh_token; - this.expires_in = data.expires_in; - } - - set(token, value) { - this[token] = value; - return this; - } -} - -class Request { - constructor(grantType, miscConfig) { - this.client_id = configs.get('DiscordClientId'); - this.client_secret = configs.get('DiscordClientSecret'); - this.grant_type = grantType; - Object.assign(this, miscConfig); - } -} - -const getOAuthTokens = async (accessCode) => new TokenSet( - await fetch(`${BASE_DISCORD_URI}/oauth2/token`, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams( - new Request('authorization_code', { - code: accessCode, - redirect_uri: configs.get('DiscordRedirectUri'), - }), - ), - }).then((res) => res.json()), -); - -const getAccessToken = async (userId) => { - const tokenSet = store.get(userId); - if (Date.now() > tokenSet.expires_in) { - store.set( - userId, - new TokenSet( - await fetch(`${BASE_DISCORD_URI}/oauth2/token`, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams( - new Request('refresh_token', { refresh_token: tokenSet.refresh_token }), - ), - }).then((res) => res.json()), - ).set('expires_at', Date.now() + tokenSet.expires_in * 1e3), - ); - } - return tokenSet.access_token; -}; - -const getUserData = async (token) => { - const data = await fetch(`${BASE_DISCORD_URI}/users/@me`, { - headers: { Authorization: `Bearer ${token.access_token}` }, - }).then((res) => res.json()); - return { - uid: data.id, - email: data.email, - username: data.username, - }; -}; - -const updateMetadata = async (req, res) => { - const metaData = new Map() - .set('isTeam', false); - const connectedUser = await req.db.get( - SQL` - SELECT users.username FROM social_lookup LEFT JOIN users on social_lookup.userId = users.id - WHERE social_lookup.provider = 'discord' AND social_lookup.identifier = ${req.data.uid}; - `, - ); - const user = await req.db.get( - SQL` - SELECT username, roles FROM users - WHERE username = ${normalise(connectedUser.username)} OR email = ${req.data.email} - LIMIT 1; - `, - ); - if (!user) { - const error = { - code: 404, - step: 1, - error: 'User not found', - hint: [ - 'Try linking your Discord account to your Pronouns.Page account first.\n\n', - 'After that, on your account settings, select "Login Methods", and ensure the slider saying:', - '"Allow getting my profile looked up by social media handles / identifiers', - '(it\'s useful for integrations, discord bots, browser extensions, …)" is enabled.\n\n', - 'If the issue persists, please contact support at support@pronouns.page', - ].join(' '), - }; - return res.status(error.code).json(error); - } - metaData.set('isTeam', user.roles !== ''); - await fetch( - `${BASE_DISCORD_URI}/users/@me/applications/${configs.get('DiscordClientId')}/role-connection`, - { - method: 'PUT', - headers: { - Authorization: `Bearer ${await getAccessToken(req.uid)}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - platform_name: 'Pronouns.Page', - metadata: Object.fromEntries(metaData.entries()).map( - ([key, value]) => [key.toLowerCase(), value], - ), - }), - }, - ); - res.send('200 OK'); -}; - -const router = Router(); - -router.get('/discord/linked-role', async (req, res) => { - res.redirect(OAUTH_URL); -}); - -router.get('/discord/oauth2', handleErrorAsync(async (req, res) => { - const { code } = req.query; - if (!code) { - return res.status(400).send('missing state'); - } - const - token = await getOAuthTokens(code), - userData = await getUserData(token); - store.set(userData.uid, token.set('expires_at', Date.now() + token.expires_in * 1000)); - await updateMetadata(Object.assign(req, { data: userData }), res); -})); - -export default router; diff --git a/server/routes/discord.ts b/server/routes/discord.ts new file mode 100644 index 000000000..4674a7e80 --- /dev/null +++ b/server/routes/discord.ts @@ -0,0 +1,168 @@ +import { Router } from 'express'; +import type { Request, Response } from 'express'; +import { handleErrorAsync } from '../../src/helpers.ts'; +import SQL from 'sql-template-strings'; +import { normalise } from './user.js'; +import type { User } from '../../src/user.ts'; + +const configs = { + discordToken: process.env.DISCORD_TOKEN!, + discordClientId: process.env.DISCORD_CLIENT_ID!, + discordClientSecret: process.env.DISCORD_CLIENT_SECRET!, + discordRedirectUri: process.env.DISCORD_REDIRECT_URI!, +}; + +const store: Map = new Map(); + +const BASE_DISCORD_URI = 'https://discord.com/api/v10'; + +const OAUTH_URL = Object.assign(new URL('/oauth2/authorize', BASE_DISCORD_URI), { + search: new URLSearchParams({ + client_id: configs.discordClientId, + redirect_uri: configs.discordRedirectUri, + response_type: 'code', + scope: [ + 'role_connections.write', + 'identify', + 'email', + ].join(' '), + prompt: 'consent', + }).toString(), +}).toString(); + +interface TokenSet { + access_token: string; + refresh_token: string; + expires_in: number; + expires_at?: number; +} + +const buildDiscordRequest = (grantType: string, miscConfig: Record): Record => ({ + client_id: configs.discordClientId, + client_secret: configs.discordClientSecret, + grant_type: grantType, + ...miscConfig, +}); + +const getOAuthTokens = async (accessCode: string): Promise => { + return await fetch(`${BASE_DISCORD_URI}/oauth2/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams( + buildDiscordRequest('authorization_code', { + code: accessCode, + redirect_uri: configs.discordRedirectUri, + }), + ), + }).then((res) => res.json()); +}; + +const getAccessToken = async (userId: string) => { + const tokenSet = store.get(userId); + if (tokenSet === undefined) { + return undefined; + } + if (Date.now() > tokenSet.expires_in) { + const token: TokenSet = await fetch(`${BASE_DISCORD_URI}/oauth2/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams( + buildDiscordRequest('refresh_token', { + refresh_token: tokenSet.refresh_token, + }), + ), + }).then((res) => res.json()); + token.expires_at = Date.now() + tokenSet.expires_in * 1e3; + store.set(userId, token); + } + return tokenSet.access_token; +}; + +const getUserData = async (token: TokenSet) => { + const data = await fetch(`${BASE_DISCORD_URI}/users/@me`, { + headers: { Authorization: `Bearer ${token.access_token}` }, + }).then((res) => res.json()); + return { + uid: data.id, + email: data.email, + username: data.username, + }; +}; + +const buildUserNotFoundResponse = (res: Response) => { + const error = { + code: 404, + step: 1, + error: 'User not found', + hint: [ + 'Try linking your Discord account to your Pronouns.Page account first.\n\n', + 'After that, on your account settings, select "Login Methods", and ensure the slider saying:', + '"Allow getting my profile looked up by social media handles / identifiers', + '(it\'s useful for integrations, discord bots, browser extensions, …)" is enabled.\n\n', + 'If the issue persists, please contact support at support@pronouns.page', + ].join(' '), + }; + return res.status(error.code).json(error); +}; + +const updateMetadata = async (req: Request, res: Response, data: { uid: string, email: string }) => { + const metaData = new Map() + .set('isTeam', false); + const connectedUser = await req.db.get>( + SQL` + SELECT users.username FROM social_lookup LEFT JOIN users on social_lookup.userId = users.id + WHERE social_lookup.provider = 'discord' AND social_lookup.identifier = ${data.uid}; + `, + ); + if (!connectedUser) { + return buildUserNotFoundResponse(res); + } + const user = await req.db.get>( + SQL` + SELECT username, roles FROM users + WHERE username = ${normalise(connectedUser.username)} OR email = ${data.email} + LIMIT 1; + `, + ); + if (!user) { + return buildUserNotFoundResponse(res); + } + metaData.set('isTeam', user.roles !== ''); + await fetch( + `${BASE_DISCORD_URI}/users/@me/applications/${configs.discordClientId}/role-connection`, + { + method: 'PUT', + headers: { + Authorization: `Bearer ${await getAccessToken(data.uid)}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + platform_name: 'Pronouns.Page', + metadata: Object.fromEntries([...metaData.entries()].map( + ([key, value]) => [key.toLowerCase(), value], + )), + }), + }, + ); + res.send('200 OK'); +}; + +const router = Router(); + +router.get('/discord/linked-role', async (req, res) => { + res.redirect(OAUTH_URL); +}); + +router.get('/discord/oauth2', handleErrorAsync(async (req, res) => { + const { code } = req.query; + if (!code) { + return res.status(400).send('missing state'); + } + const token = await getOAuthTokens(code as string); + const userData = await getUserData(token); + token.expires_at = Date.now() + token.expires_in * 1000; + store.set(userData.uid, token); + await updateMetadata(req, res, userData); +})); + +export default router;