mirror of
https://github.com/roleypoly/roleypoly.git
synced 2025-06-15 09:09:10 +00:00
feat: add audit logging via webhook (#309)
* feat: add audit logging via webhook * addd missing auditLogWebhook values in various places
This commit is contained in:
parent
5671a408c1
commit
acc604f83f
16 changed files with 488 additions and 22 deletions
|
@ -11,6 +11,7 @@ export const CreateRoleypolyData = onlyRootUsers(
|
|||
message:
|
||||
'Hey, this is kind of a demo setup so features/use cases can be shown off.\n\nThanks for using Roleypoly <3',
|
||||
features: Features.Preview,
|
||||
auditLogWebhook: null,
|
||||
categories: [
|
||||
{
|
||||
id: KSUID.randomSync().string,
|
||||
|
|
|
@ -1,8 +1,20 @@
|
|||
import { GuildDataUpdate, SessionData, UserGuildPermissions } from '@roleypoly/types';
|
||||
import { sendAuditLog, validateAuditLogWebhook } from '@roleypoly/api/utils/audit-log';
|
||||
import {
|
||||
GuildDataUpdate,
|
||||
SessionData,
|
||||
UserGuildPermissions,
|
||||
WebhookValidationStatus,
|
||||
} from '@roleypoly/types';
|
||||
import { withSession } from '../utils/api-tools';
|
||||
import { getGuildData } from '../utils/guild';
|
||||
import { GuildData } from '../utils/kv';
|
||||
import { lowPermissions, missingParameters, notFound, ok } from '../utils/responses';
|
||||
import {
|
||||
invalid,
|
||||
lowPermissions,
|
||||
missingParameters,
|
||||
notFound,
|
||||
ok,
|
||||
} from '../utils/responses';
|
||||
|
||||
export const UpdateGuild = withSession(
|
||||
(session: SessionData) =>
|
||||
|
@ -27,13 +39,42 @@ export const UpdateGuild = withSession(
|
|||
return lowPermissions();
|
||||
}
|
||||
|
||||
const oldGuildData = await getGuildData(guildID);
|
||||
const newGuildData = {
|
||||
...(await getGuildData(guildID)),
|
||||
...oldGuildData,
|
||||
...guildUpdate,
|
||||
};
|
||||
|
||||
if (oldGuildData.auditLogWebhook !== newGuildData.auditLogWebhook) {
|
||||
try {
|
||||
const validationStatus = await validateAuditLogWebhook(
|
||||
guild,
|
||||
newGuildData.auditLogWebhook
|
||||
);
|
||||
|
||||
if (validationStatus !== WebhookValidationStatus.Ok) {
|
||||
if (validationStatus === WebhookValidationStatus.NoneSet) {
|
||||
newGuildData.auditLogWebhook = null;
|
||||
} else {
|
||||
return invalid({
|
||||
what: 'webhookValidationStatus',
|
||||
webhookValidationStatus: validationStatus,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
invalid();
|
||||
}
|
||||
}
|
||||
|
||||
await GuildData.put(guildID, newGuildData);
|
||||
|
||||
try {
|
||||
await sendAuditLog(oldGuildData, guildUpdate, session.user);
|
||||
} catch (e) {
|
||||
// Catching errors here because this isn't a critical task, and could simply fail due to operator error.
|
||||
}
|
||||
|
||||
return ok();
|
||||
}
|
||||
);
|
||||
|
|
|
@ -11,6 +11,8 @@
|
|||
"@roleypoly/misc-utils": "*",
|
||||
"@roleypoly/types": "*",
|
||||
"@roleypoly/worker-emulator": "*",
|
||||
"@types/deep-equal": "^1.0.1",
|
||||
"deep-equal": "^2.0.5",
|
||||
"ksuid": "^2.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"ts-loader": "^8.3.0"
|
||||
|
|
226
packages/api/utils/audit-log.ts
Normal file
226
packages/api/utils/audit-log.ts
Normal file
|
@ -0,0 +1,226 @@
|
|||
import { userAgent } from '@roleypoly/api/utils/api-tools';
|
||||
import { uiPublicURI } from '@roleypoly/api/utils/config';
|
||||
import {
|
||||
Category,
|
||||
DiscordUser,
|
||||
GuildData,
|
||||
GuildDataUpdate,
|
||||
GuildSlug,
|
||||
WebhookValidationStatus,
|
||||
} from '@roleypoly/types';
|
||||
import deepEqual from 'deep-equal';
|
||||
import { sortBy, uniq } from 'lodash';
|
||||
|
||||
type WebhookEmbed = {
|
||||
fields: {
|
||||
name: string;
|
||||
value: string;
|
||||
inline: boolean;
|
||||
}[];
|
||||
timestamp: string;
|
||||
title: string;
|
||||
color: number;
|
||||
author?: {
|
||||
name: string;
|
||||
icon_url: string;
|
||||
};
|
||||
};
|
||||
|
||||
type WebhookPayload = {
|
||||
username: string;
|
||||
avatar_url: string;
|
||||
embeds: WebhookEmbed[];
|
||||
provider: {
|
||||
name: string;
|
||||
url: string;
|
||||
};
|
||||
};
|
||||
|
||||
type ChangeHandler = (
|
||||
oldValue: GuildDataUpdate[keyof GuildDataUpdate],
|
||||
newValue: GuildData[keyof GuildDataUpdate]
|
||||
) => WebhookEmbed[];
|
||||
|
||||
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...`,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
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}`),
|
||||
];
|
||||
};
|
|
@ -128,17 +128,22 @@ export const updateGuildMemberRoles = async (
|
|||
|
||||
export const getGuildData = async (id: string): Promise<GuildDataT> => {
|
||||
const guildData = await GuildData.get<GuildDataT>(id);
|
||||
const empty = {
|
||||
id,
|
||||
message: '',
|
||||
categories: [],
|
||||
features: Features.None,
|
||||
auditLogWebhook: null,
|
||||
};
|
||||
|
||||
if (!guildData) {
|
||||
return {
|
||||
id,
|
||||
message: '',
|
||||
categories: [],
|
||||
features: Features.None,
|
||||
};
|
||||
return empty;
|
||||
}
|
||||
|
||||
return guildData;
|
||||
return {
|
||||
...empty,
|
||||
...guildData,
|
||||
};
|
||||
};
|
||||
|
||||
const calculateRoleSafety = (role: Role | APIRole, highestBotRolePosition: number) => {
|
||||
|
|
|
@ -44,6 +44,7 @@ export const transformLegacyGuild = (guild: LegacyGuildData): GuildData => {
|
|||
id: guild.id,
|
||||
message: guild.message,
|
||||
features: Features.LegacyGuild,
|
||||
auditLogWebhook: null,
|
||||
categories: sortBy(Object.values(guild.categories), 'position').map(
|
||||
(category, idx) => ({
|
||||
...category,
|
||||
|
|
|
@ -14,3 +14,6 @@ export const conflict = () => respond({ error: 'conflict' }, { status: 409 });
|
|||
|
||||
export const rateLimited = () =>
|
||||
respond({ error: 'rate limit hit, enhance your calm' }, { status: 419 });
|
||||
|
||||
export const invalid = (obj: any = {}) =>
|
||||
respond({ err: 'client sent something invalid', data: obj }, { status: 400 });
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue