diff --git a/packages/api/handlers/update-guild.ts b/packages/api/handlers/update-guild.ts index 0c37729..c4985b8 100644 --- a/packages/api/handlers/update-guild.ts +++ b/packages/api/handlers/update-guild.ts @@ -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(); } ); diff --git a/packages/api/package.json b/packages/api/package.json index 5ff6f12..b5d0aee 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -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" diff --git a/packages/api/utils/audit-log.ts b/packages/api/utils/audit-log.ts new file mode 100644 index 0000000..e7c175f --- /dev/null +++ b/packages/api/utils/audit-log.ts @@ -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 = { + 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 => { + 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}`), + ]; +}; diff --git a/packages/api/utils/guild.ts b/packages/api/utils/guild.ts index de92f4b..0f37438 100644 --- a/packages/api/utils/guild.ts +++ b/packages/api/utils/guild.ts @@ -128,17 +128,22 @@ export const updateGuildMemberRoles = async ( export const getGuildData = async (id: string): Promise => { const guildData = await GuildData.get(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) => { diff --git a/packages/api/utils/responses.ts b/packages/api/utils/responses.ts index 7b8936b..7838d6d 100644 --- a/packages/api/utils/responses.ts +++ b/packages/api/utils/responses.ts @@ -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 }); diff --git a/packages/design-system/atoms/space/Space.stories.tsx b/packages/design-system/atoms/space/Space.stories.tsx index ac0e768..d21882e 100644 --- a/packages/design-system/atoms/space/Space.stories.tsx +++ b/packages/design-system/atoms/space/Space.stories.tsx @@ -1,14 +1,22 @@ import * as React from 'react'; -import { Space as SpaceComponent } from './Space'; +import { LinedSpace, Space } from './Space'; export default { - title: 'Atoms', + title: 'Atoms/Space', }; -export const Space = () => ( +export const space = () => ( <> hello world - + + but im over here + +); + +export const linedSpace = () => ( + <> + hello world + but im over here ); diff --git a/packages/design-system/atoms/space/Space.tsx b/packages/design-system/atoms/space/Space.tsx index 84b8d7e..15c27a6 100644 --- a/packages/design-system/atoms/space/Space.tsx +++ b/packages/design-system/atoms/space/Space.tsx @@ -1,5 +1,17 @@ -import styled from 'styled-components'; +import { palette } from '@roleypoly/design-system/atoms/colors'; +import styled, { css } from 'styled-components'; export const Space = styled.div` height: 15px; `; + +export const LinedSpace = styled.div<{ width?: number }>` + height: 7.5px; + margin-top: 7.5px; + border-top: 1px solid ${palette.taupe300}; + ${(props) => + props.width && + css` + width: ${props.width}px; + `} +`; diff --git a/packages/design-system/molecules/server-utilities/ServerUtilities.tsx b/packages/design-system/molecules/server-utilities/ServerUtilities.tsx new file mode 100644 index 0000000..ed9a385 --- /dev/null +++ b/packages/design-system/molecules/server-utilities/ServerUtilities.tsx @@ -0,0 +1,72 @@ +import { palette } from '@roleypoly/design-system/atoms/colors'; +import { FaderOpacity } from '@roleypoly/design-system/atoms/fader'; +import { TextInput } from '@roleypoly/design-system/atoms/text-input'; +import { AmbientLarge, Text } from '@roleypoly/design-system/atoms/typography'; +import { MessageBox } from '@roleypoly/design-system/organisms/role-picker/RolePicker.styled'; +import { GuildData, WebhookValidationStatus } from '@roleypoly/types'; +import { GoAlert, GoInfo } from 'react-icons/go'; +import ReactTooltip from 'react-tooltip'; +import styled from 'styled-components'; + +type Props = { + onChange: (guildData: GuildData) => void; + guildData: GuildData; + validationStatus: WebhookValidationStatus | null; +}; + +export const ServerUtilities = (props: Props) => { + return ( + + + (optional) Webhook URL for Audit Logging{' '} + + + + props.onChange({ ...props.guildData, auditLogWebhook: event.target.value }) + } + /> + + + + + + ); +}; + +const ValidationStatus = (props: Pick) => { + switch (props.validationStatus) { + case WebhookValidationStatus.NotDiscordURL: + return ( + + URL must be to a Discord webhook, starting with + "https://discord.com/api/webhooks/". + + ); + case WebhookValidationStatus.NotSameGuild: + return ( + + Webhook must be in the same guild you are currently editing. + + ); + case WebhookValidationStatus.DoesNotExist: + return ( + + This webhook doesn't exist. + + ); + default: + return  ; + } +}; + +const Alert = styled(GoAlert)` + color: ${palette.red400}; + position: relative; + top: 2px; +`; diff --git a/packages/design-system/organisms/editor-shell/EditorShell.tsx b/packages/design-system/organisms/editor-shell/EditorShell.tsx index 48433b0..0258b00 100644 --- a/packages/design-system/organisms/editor-shell/EditorShell.tsx +++ b/packages/design-system/organisms/editor-shell/EditorShell.tsx @@ -1,10 +1,16 @@ -import { Space } from '@roleypoly/design-system/atoms/space'; +import { LinedSpace, Space } from '@roleypoly/design-system/atoms/space'; import { EditableServerMessage } from '@roleypoly/design-system/molecules/editable-server-message'; import { ServerMasthead } from '@roleypoly/design-system/molecules/server-masthead'; +import { ServerUtilities } from '@roleypoly/design-system/molecules/server-utilities/ServerUtilities'; import { SecondaryEditing } from '@roleypoly/design-system/organisms/masthead'; import { Container } from '@roleypoly/design-system/organisms/role-picker/RolePicker.styled'; import { ServerCategoryEditor } from '@roleypoly/design-system/organisms/server-category-editor'; -import { Category, PresentableGuild } from '@roleypoly/types'; +import { + Category, + GuildData, + PresentableGuild, + WebhookValidationStatus, +} from '@roleypoly/types'; import deepEqual from 'deep-equal'; import React from 'react'; @@ -13,17 +19,26 @@ export type EditorShellProps = { onGuildChange?: (guild: PresentableGuild) => void; onCategoryChange?: (category: Category) => void; onMessageChange?: (message: PresentableGuild['data']['message']) => void; + errors: { + webhookValidation: WebhookValidationStatus; + }; }; export const EditorShell = (props: EditorShellProps) => { const [guild, setGuild] = React.useState(props.guild); + const [errors, setErrors] = React.useState(props.errors); React.useEffect(() => { setGuild(props.guild); }, [props.guild]); + React.useEffect(() => { + setErrors(props.errors); + }, [props.errors]); + const reset = () => { setGuild(props.guild); + setErrors({ webhookValidation: WebhookValidationStatus.Ok }); }; const replaceCategories = (categories: Category[]) => { @@ -38,6 +53,12 @@ export const EditorShell = (props: EditorShellProps) => { }); }; + const updateGuildData = (data: PresentableGuild['data']) => { + setGuild((currentGuild) => { + return { ...currentGuild, data }; + }); + }; + const doSubmit = () => { props.onGuildChange?.(guild); }; @@ -65,7 +86,48 @@ export const EditorShell = (props: EditorShellProps) => { /> + + ); }; + +const validateWebhook = ( + webhook: GuildData['auditLogWebhook'], + validationStatus: WebhookValidationStatus +) => { + if (!webhook) { + return WebhookValidationStatus.NoneSet; + } + + try { + const url = new URL(webhook); + + if ( + url.hostname !== 'discord.com' || + url.protocol !== 'https:' || + url.pathname.startsWith('api/webhooks/') + ) { + return WebhookValidationStatus.NotDiscordURL; + } + } catch (e) { + return WebhookValidationStatus.Ok; + } + + if ( + validationStatus !== WebhookValidationStatus.Ok && + validationStatus !== WebhookValidationStatus.NoneSet + ) { + return validationStatus; + } + + return WebhookValidationStatus.Ok; +}; diff --git a/packages/design-system/templates/editor/Editor.stories.tsx b/packages/design-system/templates/editor/Editor.stories.tsx index 0cd8c67..1f97291 100644 --- a/packages/design-system/templates/editor/Editor.stories.tsx +++ b/packages/design-system/templates/editor/Editor.stories.tsx @@ -14,6 +14,7 @@ export default { ), ], args: { + errors: { validationStatus: 0 }, guilds: mastheadSlugs, user: user, guild: guildEnum.guilds[0], diff --git a/packages/design-system/templates/editor/Editor.tsx b/packages/design-system/templates/editor/Editor.tsx index 6e6169c..36d76d3 100644 --- a/packages/design-system/templates/editor/Editor.tsx +++ b/packages/design-system/templates/editor/Editor.tsx @@ -11,7 +11,9 @@ export const EditorTemplate = ( props; return ( - + ); }; + +export type EditorErrors = EditorShellProps['errors']; diff --git a/packages/types/Guild.ts b/packages/types/Guild.ts index 9561be7..0c7f6af 100644 --- a/packages/types/Guild.ts +++ b/packages/types/Guild.ts @@ -20,6 +20,7 @@ export type GuildData = { message: string; categories: Category[]; features: Features; + auditLogWebhook: string | null; }; export type GuildDataUpdate = Omit, 'id'>; @@ -48,3 +49,11 @@ export type GuildSlug = { icon: string; permissionLevel: UserGuildPermissions; }; + +export enum WebhookValidationStatus { + Ok, + NoneSet, + DoesNotExist, + NotSameGuild, + NotDiscordURL, +} diff --git a/packages/web/src/pages/editor.tsx b/packages/web/src/pages/editor.tsx index 63455d7..c123568 100644 --- a/packages/web/src/pages/editor.tsx +++ b/packages/web/src/pages/editor.tsx @@ -1,10 +1,11 @@ import { navigate, Redirect } from '@reach/router'; -import { EditorTemplate } from '@roleypoly/design-system/templates/editor'; +import { EditorErrors, EditorTemplate } from '@roleypoly/design-system/templates/editor'; import { GenericLoadingTemplate } from '@roleypoly/design-system/templates/generic-loading'; import { GuildDataUpdate, PresentableGuild, UserGuildPermissions, + WebhookValidationStatus, } from '@roleypoly/types'; import * as React from 'react'; import { useAppShellProps } from '../contexts/app-shell/AppShellContext'; @@ -25,6 +26,9 @@ const Editor = (props: EditorProps) => { const [guild, setGuild] = React.useState(null); const [pending, setPending] = React.useState(false); + const [errors, setErrors] = React.useState({ + webhookValidation: WebhookValidationStatus.Ok, + }); React.useEffect(() => { const shouldPullUncached = (): boolean => { @@ -87,6 +91,7 @@ const Editor = (props: EditorProps) => { const updatePayload: GuildDataUpdate = { message: guild.data.message, categories: guild.data.categories, + auditLogWebhook: guild.data.auditLogWebhook, }; const response = await authedFetch(`/update-guild/${serverID}`, { @@ -96,16 +101,31 @@ const Editor = (props: EditorProps) => { if (response.status === 200) { setGuild(guild); - setPending(false); + navigate(`/s/${props.serverID}`); } - navigate(`/s/${props.serverID}`); + if (response.status === 400) { + const error = await response.json(); + if (error.data.what === 'webhookValidationStatus') { + setErrors((errors) => ({ + ...errors, + webhookValidation: error.data.webhookValidationStatus, + })); + } + } + + setPending(false); }; return ( <> - <EditorTemplate {...appShellProps} guild={guild} onGuildChange={onGuildChange} /> + <EditorTemplate + {...appShellProps} + guild={guild} + onGuildChange={onGuildChange} + errors={errors} + /> </> ); };