Merge branch 'benjamin' into 'main'

Minor alterations to Admin systems and Discord Internals

See merge request PronounsPage/PronounsPage!461
This commit is contained in:
Valentyne Stigloher 2024-05-26 16:32:30 +00:00
commit 446000e54f
3 changed files with 75 additions and 49 deletions

View File

@ -67,7 +67,7 @@
<nuxt-link v-if="$isGranted('*')" :to="`/admin/audit-log/${s.el.username}/${s.el.id}`" class="badge bg-primary text-white"> <nuxt-link v-if="$isGranted('*')" :to="`/admin/audit-log/${s.el.username}/${s.el.id}`" class="badge bg-primary text-white">
<Icon v="file-search" /> <Icon v="file-search" />
</nuxt-link> </nuxt-link>
<a v-if="$isGranted('*')" href="#" class="badge bg-danger text-white" @click.prevent="erasure(s.el.id, s.el.email)"><Icon v="truck-plow" /></a> <a v-if="$isGranted('*') || $isGranted('community')" href="#" class="badge bg-danger text-white" @click.prevent="erasure(s.el.id, s.el.email)"><Icon v="truck-plow" /></a>
</td> </td>
<td> <td>
{{ $datetime($ulidTime(s.el.id)) }} {{ $datetime($ulidTime(s.el.id)) }}

View File

@ -1,10 +1,32 @@
import { Router } from 'express'; import { Router } from 'express';
import { handleErrorAsync } from '../../src/helpers.ts'; import { handleErrorAsync } from '../../src/helpers.ts';
import SQL from 'sql-template-strings'; 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 store = new Map();
const BASE_DISCORD_URI = 'https://discord.com/api/v10'; 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 { class TokenSet {
constructor(data) { constructor(data) {
this.access_token = data.access_token; this.access_token = data.access_token;
@ -18,12 +40,6 @@ class TokenSet {
} }
} }
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);
class Request { class Request {
constructor(grantType, miscConfig) { constructor(grantType, miscConfig) {
this.client_id = configs.get('DiscordClientId'); this.client_id = configs.get('DiscordClientId');
@ -33,73 +49,81 @@ class Request {
} }
} }
const getOAuthUrl = () => 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',
].join(' '),
prompt: 'consent',
}).toString(),
}).toString();
const getOAuthTokens = async (accessCode) => new TokenSet( const getOAuthTokens = async (accessCode) => new TokenSet(
await fetch(`${BASE_DISCORD_URI}/oauth2/token`, { await fetch(`${BASE_DISCORD_URI}/oauth2/token`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams( body: new URLSearchParams(
new Request('authorization_code', { code: accessCode, redirect_uri: configs.get('DiscordRedirectUri') }), new Request('authorization_code', {
code: accessCode,
redirect_uri: configs.get('DiscordRedirectUri'),
}),
), ),
}).then((res) => res.json()), }).then((res) => res.json()),
); );
const getAccessToken = async (userId) => { const getAccessToken = async (userId) => {
if (Date.now() > store.get(`discord-${userId}`).expires_in) { const tokenSet = store.get(userId);
if (Date.now() > tokenSet.expires_in) {
store.set( store.set(
`discord-${userId}`, userId,
new TokenSet( new TokenSet(
await fetch(`${BASE_DISCORD_URI}/oauth2/token`, { await fetch(`${BASE_DISCORD_URI}/oauth2/token`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams( body: new URLSearchParams(
new Request('refresh_token', { refresh_token: store.get(`discord-${userId}`).refresh_token }), new Request('refresh_token', { refresh_token: tokenSet.refresh_token }),
), ),
}).then((res) => res.json()), }).then((res) => res.json()),
).set('expires_at', Date.now() + store.get(`discord-${userId}`).expires_in * 1e3), ).set('expires_at', Date.now() + tokenSet.expires_in * 1e3),
); );
} }
return store.get(`discord-${userId}`).access_token; return tokenSet.access_token;
}; };
const getUserData = async (token) => await fetch(`${BASE_DISCORD_URI}/users/@me`, { const getUserData = async (token) => {
headers: { Authorization: `Bearer ${token.access_token}` }, const data = await fetch(`${BASE_DISCORD_URI}/users/@me`, {
}).then((res) => res.json()); headers: { Authorization: `Bearer ${token.access_token}` },
}).then((res) => res.json());
return {
uid: data.id,
email: data.email,
username: data.username,
};
};
const updateMetadata = async (req) => { const updateMetadata = async (req, res) => {
const metaData = new Map() const metaData = new Map()
.set('isTeam', false); .set('isTeam', false);
const user = await req.db.get( const connectedUser = await req.db.get(
SQL` SQL`
SELECT users.username FROM social_lookup LEFT JOIN users on social_lookup.userId = users.id 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.uid}; 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) { if (!user) {
return; 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 userRoles = await req.db.get( metaData.set('isTeam', user.roles !== '');
SQL`
SELECT users.roles != '' AS team FROM users
WHERE users.usernameNorm = ${user.username.trim().toLowerCase()};
`,
);
if (!userRoles) {
return;
}
metaData.set('isTeam', userRoles.team === 1);
await fetch( await fetch(
`${BASE_DISCORD_URI}/users/@me/applications/${configs.get('DiscordClientId')}/role-connection`, `${BASE_DISCORD_URI}/users/@me/applications/${configs.get('DiscordClientId')}/role-connection`,
{ {
@ -110,16 +134,19 @@ const updateMetadata = async (req) => {
}, },
body: JSON.stringify({ body: JSON.stringify({
platform_name: 'Pronouns.Page', platform_name: 'Pronouns.Page',
metadata: Object.fromEntries(metaData.entries().map(([key, value]) => [key.toLowerCase(), value])), metadata: Object.fromEntries(metaData.entries().map(
([key, value]) => [key.toLowerCase(), value],
)),
}), }),
}, },
); );
res.send('200 OK');
}; };
const router = Router(); const router = Router();
router.get('/discord/linked-role', async (req, res) => { router.get('/discord/linked-role', async (req, res) => {
res.redirect(getOAuthUrl()); res.redirect(OAUTH_URL);
}); });
router.get('/discord/oauth2', handleErrorAsync(async (req, res) => { router.get('/discord/oauth2', handleErrorAsync(async (req, res) => {
@ -127,9 +154,8 @@ router.get('/discord/oauth2', handleErrorAsync(async (req, res) => {
const const
token = await getOAuthTokens(code), token = await getOAuthTokens(code),
userData = await getUserData(token); userData = await getUserData(token);
store.set(`discord-${userData.user.id}`, token.set('expires_at', Date.now() + token.expires_in * 1000)); store.set(userData.uid, token.set('expires_at', Date.now() + token.expires_in * 1000));
await updateMetadata(Object.assign(req, { uid: userData.user.id })); await updateMetadata(Object.assign(req, { data: userData }), res);
res.send('200 OK');
})); }));
export default router; export default router;

View File

@ -568,7 +568,7 @@ router.post('/user/delete', handleErrorAsync(async (req, res) => {
})); }));
router.post('/user/data-erasure/:id', handleErrorAsync(async (req, res) => { router.post('/user/data-erasure/:id', handleErrorAsync(async (req, res) => {
if (!req.isGranted('*')) { if (!req.isGranted('*') && !req.isGranted('community')) {
return res.status(401).json({ error: 'Unauthorised' }); return res.status(401).json({ error: 'Unauthorised' });
} }