v3/packages/api/historic/audit-log.ts~
Katalina 3291f9aacc
big overhaul (#474)
* miniflare init

* feat(api): add tests

* chore: more tests, almost 100%

* add sessions/state spec

* add majority of routes and datapaths, start on interactions

* nevermind, no interactions

* nevermind x2, tweetnacl is bad but SubtleCrypto has what we need apparently

* simplify interactions verify

* add brute force interactions tests

* every primary path API route is refactored!

* automatically import from legacy, or die trying.

* check that we only fetch legacy once, ever

* remove old-src, same some historic pieces

* remove interactions & worker-utils package, update misc/types

* update some packages we don't need specific pinning for anymore

* update web references to API routes since they all changed

* fix all linting issues, upgrade most packages

* fix tests, divorce enzyme where-ever possible

* update web, fix integration issues

* pre-build api

* fix tests

* move api pretest to api package.json instead of CI

* remove interactions from terraform, fix deploy side configs

* update to tf 1.1.4

* prevent double writes to worker in GCS, port to newer GCP auth workflow

* fix api.tf var refs, upgrade node action

* change to curl-based script upload for worker script due to terraform provider limitations

* oh no, cloudflare freaked out :(
2022-01-31 20:35:22 -05:00

252 lines
6.4 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { uiPublicURI } from '@roleypoly/api/utils/config';
import {
Category,
DiscordUser,
Embed,
GuildData,
GuildDataUpdate,
GuildSlug,
WebhookValidationStatus,
} from '@roleypoly/types';
import { userAgent } from '@roleypoly/worker-utils';
import deepEqual from 'deep-equal';
import { sortBy, uniq } from 'lodash';
type WebhookPayload = {
username: string;
avatar_url: string;
embeds: Embed[];
provider: {
name: string;
url: string;
};
};
type ChangeHandler = (
oldValue: GuildDataUpdate[keyof GuildDataUpdate],
newValue: GuildData[keyof GuildDataUpdate]
) => Embed[];
const changeHandlers: Record<keyof GuildDataUpdate, ChangeHandler> = {
message: (oldValue, newValue) => [
{
timestamp: new Date().toISOString(),
color: 0x453e3d,
fields: [
{
name: 'Old Message',
value: oldValue as string,
inline: false,
},
{
name: 'New Message',
value: newValue as string,
inline: false,
},
],
title: `Server message was updated...`,
},
],
auditLogWebhook: (oldValue, newValue) => [
{
timestamp: new Date().toISOString(),
color: 0x5d5352,
fields: [
{
name: 'Old Webhook ID',
value: !oldValue ? '*unset*' : (oldValue as string).split('/')[5],
inline: false,
},
{
name: 'New Webhook ID',
value: !newValue ? '*unset*' : (newValue as string).split('/')[5],
inline: false,
},
],
title: `Audit Log webhook URL was changed...`,
},
],
categories: (oldValue, newValue) => [
{
timestamp: new Date().toISOString(),
color: 0xab9b9a,
fields: [
{
name: 'Changed Categories',
value: getChangedCategories(
oldValue as Category[],
newValue as Category[]
).join('\n'),
inline: false,
},
],
title: `Categories were changed...`,
},
],
accessControl: (oldValue, newValue) => [
{
timestamp: new Date().toISOString(),
color: 0xab9b9a,
fields: [
{
name: 'Changed Access Control',
value: getChangedAccessControl(
oldValue as GuildDataUpdate['accessControl'],
newValue as GuildDataUpdate['accessControl']
).join('\n'),
inline: false,
},
],
title: `Access Control was changed...`,
},
],
};
export const sendAuditLog = async (
guild: GuildData,
guildUpdate: GuildDataUpdate,
user: DiscordUser
) => {
const auditLogWebhooks = uniq([
guild.auditLogWebhook || '',
guildUpdate.auditLogWebhook || '',
]).filter((webhook) => webhook !== '');
if (auditLogWebhooks.length === 0) {
return;
}
const keys = Object.keys(guildUpdate) as (keyof GuildDataUpdate)[];
const webhookPayload: WebhookPayload = {
username: 'Roleypoly (Audit Log)',
avatar_url: `https://next.roleypoly.com/logo192.png`, //TODO: change to roleypoly.com when swapped.
embeds: [
{
fields: [],
timestamp: new Date().toISOString(),
title: `${user.username}#${user.discriminator} has edited Roleypoly settings`,
color: 0x332d2d,
author: {
name: user.username,
icon_url: `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png`,
},
},
],
provider: {
name: 'Roleypoly',
url: uiPublicURI,
},
};
for (let key of keys) {
if (deepEqual(guildUpdate[key], guild[key])) {
continue;
}
const handler = changeHandlers[key];
if (!handler) {
continue;
}
const changeFields = handler(guild[key], guildUpdate[key]);
webhookPayload.embeds.push(...changeFields);
}
if (webhookPayload.embeds.length === 1) {
// No changes, don't bother sending
return;
}
// Colors are in order already, so use them to order the embeds.
webhookPayload.embeds = sortBy(webhookPayload.embeds, 'color');
const doWebhook = (webhook: string) =>
fetch(webhook, {
method: 'POST',
body: JSON.stringify(webhookPayload),
headers: {
'content-type': 'application/json',
'user-agent': userAgent,
},
});
await Promise.all(auditLogWebhooks.map((webhook) => doWebhook(webhook)));
};
export const validateAuditLogWebhook = async (
guild: GuildSlug,
webhook: string | null
): Promise<WebhookValidationStatus> => {
if (!webhook) {
return WebhookValidationStatus.NoneSet;
}
const url = new URL(webhook);
if (
url.hostname !== 'discord.com' ||
url.protocol !== 'https:' ||
url.pathname.startsWith('api/webhooks/')
) {
return WebhookValidationStatus.NotDiscordURL;
}
const response = await fetch(webhook, { method: 'GET' });
if (response.status !== 200) {
return WebhookValidationStatus.DoesNotExist;
}
const webhookData = await response.json();
if (webhookData.guild_id !== guild.id) {
return WebhookValidationStatus.NotSameGuild;
}
return WebhookValidationStatus.Ok;
};
const getChangedCategories = (oldCategories: Category[], newCategories: Category[]) => {
const addedCategories = newCategories.filter(
(c) => !oldCategories.find((o) => o.id === c.id)
);
const removedCategories = oldCategories.filter(
(c) => !newCategories.find((o) => o.id === c.id)
);
const changedCategories = newCategories.filter(
(c) =>
oldCategories.find((o) => o.id === c.id) &&
!deepEqual(
oldCategories.find((o) => o.id === c.id),
newCategories.find((o) => o.id === c.id)
)
);
return [
...addedCategories.map((c) => ` **Added** ${c.name}`),
...removedCategories.map((c) => ` **Removed** ${c.name}`),
...changedCategories.map((c) => `🔧 **Changed** ${c.name}`),
];
};
const getChangedAccessControl = (
oldAccessControl: GuildDataUpdate['accessControl'],
newAccessControl: GuildDataUpdate['accessControl']
) => {
const pendingChanged = newAccessControl.blockPending !== oldAccessControl.blockPending;
return [
`✅ Allowed roles: ${
newAccessControl.allowList.map((role) => `<@&${role}>`).join(', ') || `*all roles*`
}`,
`❌ Blocked roles: ${
newAccessControl.blockList.map((role) => `<@&${role}>`).join(', ') || `*no roles*`
}`,
...(pendingChanged
? [
`🔧 Pending/Welcome Screening users are ${
newAccessControl.blockPending ? 'blocked ❌' : 'allowed ✔'
}`,
]
: []),
];
};