mirror of
https://gitlab.com/PronounsPage/PronounsPage.git
synced 2025-09-27 06:52:35 -04:00
Merge branch 'discord' into 'main'
discord linked roles fixes and typings See merge request PronounsPage/PronounsPage!483
This commit is contained in:
commit
ccba73890f
@ -41,7 +41,7 @@ import blogRoute from './routes/blog.js';
|
|||||||
import calendarRoute from './routes/calendar.js';
|
import calendarRoute from './routes/calendar.js';
|
||||||
import translationsRoute from './routes/translations.ts';
|
import translationsRoute from './routes/translations.ts';
|
||||||
import subscriptionRoute from './routes/subscription.js';
|
import subscriptionRoute from './routes/subscription.js';
|
||||||
import discord from './routes/discord.js';
|
import discord from './routes/discord.ts';
|
||||||
|
|
||||||
const MemoryStore = memorystore(session);
|
const MemoryStore = memorystore(session);
|
||||||
|
|
||||||
|
@ -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;
|
|
168
server/routes/discord.ts
Normal file
168
server/routes/discord.ts
Normal file
@ -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<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_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<Pick<User, 'username'>>(
|
||||||
|
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<Pick<User, 'username' | 'roles'>>(
|
||||||
|
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;
|
@ -662,7 +662,9 @@ router.get('/user/social/:provider', handleErrorAsync(async (req, res) => {
|
|||||||
if (!req.session.socialRedirect) {
|
if (!req.session.socialRedirect) {
|
||||||
return `/${config.user.route}`;
|
return `/${config.user.route}`;
|
||||||
}
|
}
|
||||||
const host = process.env.NODE_ENV === 'development' ? '' : buildLocaleList(config.locale, true)[req.session.socialRedirect].url;
|
const host = process.env.NODE_ENV === 'development' || process.env.ENV === 'test'
|
||||||
|
? ''
|
||||||
|
: buildLocaleList(config.locale, true)[req.session.socialRedirect].url;
|
||||||
delete req.session.socialRedirect;
|
delete req.session.socialRedirect;
|
||||||
|
|
||||||
return `${host}/api/user/social-redirect-callback/${encodeURIComponent(token)}`;
|
return `${host}/api/user/social-redirect-callback/${encodeURIComponent(token)}`;
|
||||||
|
Loading…
x
Reference in New Issue
Block a user