mirror of
https://gitlab.com/PronounsPage/PronounsPage.git
synced 2025-09-25 14:09:03 -04:00
152 lines
5.1 KiB
TypeScript
152 lines
5.1 KiB
TypeScript
import { Router } from 'express';
|
|
import type { Request, Response } from 'express';
|
|
import { handleErrorAsync } from '../../src/helpers.ts';
|
|
import SQL from 'sql-template-strings';
|
|
import type { UserRow } from './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<string, TokenSet> = 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<string, string>): Record<string, string> => ({
|
|
client_id: configs.discordClientId,
|
|
client_secret: configs.discordClientSecret,
|
|
grant_type: grantType,
|
|
...miscConfig,
|
|
});
|
|
|
|
const getOAuthTokens = async (accessCode: string): Promise<TokenSet> => {
|
|
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_at!) {
|
|
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 token.access_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 updateMetadata = async (req: Request, res: Response, data: { uid: string, email: string }) => {
|
|
const user = await req.db.get<Pick<UserRow, 'username' | 'roles'>>(
|
|
SQL`
|
|
SELECT users.username, users.roles FROM social_lookup
|
|
LEFT JOIN users on social_lookup.userId = users.id
|
|
WHERE social_lookup.provider = 'discord' AND social_lookup.identifier = ${data.uid};`,
|
|
);
|
|
if (!user) {
|
|
const error = {
|
|
code: 404,
|
|
error: 'User not found',
|
|
hint: [
|
|
'We are unable to locate your account using the information provided to us by Discord.',
|
|
'Please insure you have COMPLETELY followed all of the steps shown in the verification channel.',
|
|
'If you are still having an issue after confirming the above,',
|
|
'please contact the Modmail bot for further assistance.',
|
|
].join(' '),
|
|
};
|
|
return res.status(error.code).json(error);
|
|
}
|
|
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',
|
|
platform_username: user.username,
|
|
metadata: {
|
|
isteam: user.roles !== '',
|
|
isuser: true,
|
|
},
|
|
}),
|
|
},
|
|
);
|
|
res.send('200 OK');
|
|
};
|
|
|
|
const router = Router();
|
|
|
|
router.get('/discord/linked-role', async (_req: Request, res: Response) => {
|
|
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;
|