overlay fixes, tokens fix

This commit is contained in:
Michael Peters 2022-02-13 14:27:33 -06:00
parent e0d0b2a9df
commit 609947b350
7 changed files with 2032 additions and 1567 deletions

2519
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -93,7 +93,7 @@ $borderRadius: 8px;
.text-input {
flex: 1;
padding: 12px 12px 12px 0;
max-height: 300px;
max-height: 180px;
overflow-x: hidden;
overflow-y: scroll;
overflow-wrap: anywhere;

View File

@ -15,7 +15,12 @@ import Button from '../components/button';
import TokenRow from '../components/token-row';
import CombinedGuild from '../../guild-combined';
import { useRecoilValue } from 'recoil';
import { guildMetaState, guildResourceSoftImgSrcState, guildTokensState, useRecoilValueSoftImgSrc } from '../require/atoms';
import {
guildMetaState,
guildResourceSoftImgSrcState,
guildTokensState,
useRecoilValueSoftImgSrc,
} from '../require/atoms';
import { isFailed, isLoaded } from '../require/loadables';
export interface GuildInvitesDisplayProps {
@ -32,7 +37,12 @@ const GuildInvitesDisplay: FC<GuildInvitesDisplayProps> = (props: GuildInvitesDi
const [expiresFromNow, setExpiresFromNow] = useState<Duration | null>(moment.duration(1, 'day'));
const [expiresFromNowText, setExpiresFromNowText] = useState<string>('1 day');
const iconSrc = useRecoilValueSoftImgSrc(guildResourceSoftImgSrcState({ guildId: guild.id, resourceId: isLoaded(guildMeta) ? guildMeta.value.iconResourceId : null }));
const iconSrc = useRecoilValueSoftImgSrc(
guildResourceSoftImgSrcState({
guildId: guild.id,
resourceId: isLoaded(guildMeta) ? guildMeta.value.iconResourceId : null,
}),
);
useEffect(() => {
if (expiresFromNowText === 'never') {
@ -46,7 +56,9 @@ const GuildInvitesDisplay: FC<GuildInvitesDisplayProps> = (props: GuildInvitesDi
const [createTokenFunc, tokenButtonText, tokenButtonShaking, _, createTokenFailMessage] = useAsyncSubmitButton(
async () => {
try {
const createdToken = await guild.requestDoCreateToken(expiresFromNowText === 'never' ? null : expiresFromNowText);
const createdToken = await guild.requestDoCreateToken(
expiresFromNowText === 'never' ? null : expiresFromNowText,
);
return { result: createdToken, errorMessage: null };
} catch (e: unknown) {
LOG.error('error creating token', e);
@ -54,7 +66,7 @@ const GuildInvitesDisplay: FC<GuildInvitesDisplayProps> = (props: GuildInvitesDi
}
},
[guild, expiresFromNowText],
{ start: 'Create Token', done: 'Create Another Token' },
{ start: 'Create Token', done: 'Create Token' },
);
const errorMessage = useMemo(() => {
@ -65,7 +77,9 @@ const GuildInvitesDisplay: FC<GuildInvitesDisplayProps> = (props: GuildInvitesDi
const tokenElements = useMemo(() => {
if (isFailed(tokens)) return <div className="tokens-failed">Unable to load tokens</div>; // TODO: Try Again
if (!isLoaded(tokens)) return null; // TODO: Pending indicator
return tokens.value.map((token: Token) => <TokenRow key={guild.id + token.token} url={url} token={token} guild={guild} />);
return tokens.value.map((token: Token) => (
<TokenRow key={guild.id + token.token} url={url} token={token} guild={guild} />
));
}, [url, guild, tokens]);
return (
@ -92,7 +106,12 @@ const GuildInvitesDisplay: FC<GuildInvitesDisplayProps> = (props: GuildInvitesDi
</Button>
</div>
</div>
<InvitePreview name={isLoaded(guildMeta) ? guildMeta.value.name : 'Your Guild'} iconSrc={iconSrc} url={url} expiresFromNow={expiresFromNow} />
<InvitePreview
name={isLoaded(guildMeta) ? guildMeta.value.name : 'Your Guild'}
iconSrc={iconSrc}
url={url}
expiresFromNow={expiresFromNow}
/>
</div>
</div>
<div className="divider" />

View File

@ -22,13 +22,20 @@ const GuildOverviewDisplay: FC<GuildOverviewDisplayProps> = (props: GuildOvervie
const guildMeta = useRecoilValue(guildMetaState(guild.id));
const iconResource = useRecoilValue(guildResourceState({ guildId: guild.id, resourceId: isLoaded(guildMeta) ? guildMeta.value.iconResourceId : null }));
const iconResource = useRecoilValue(
guildResourceState({
guildId: guild.id,
resourceId: isLoaded(guildMeta) ? guildMeta.value.iconResourceId : null,
}),
);
const [savedName, setSavedName] = useState<string | null>(null);
const [savedIconBuff, setSavedIconBuff] = useState<Buffer | null>(null);
const [savedName, setSavedName] = useState<string | null>(isLoaded(guildMeta) ? guildMeta.value.name : null);
const [savedIconBuff, setSavedIconBuff] = useState<Buffer | null>(
isLoaded(iconResource) ? iconResource.value.data : null,
);
const [name, setName] = useState<string | null>(null);
const [iconBuff, setIconBuff] = useState<Buffer | null>(null);
const [name, setName] = useState<string | null>(isLoaded(guildMeta) ? guildMeta.value.name : null);
const [iconBuff, setIconBuff] = useState<Buffer | null>(isLoaded(iconResource) ? iconResource.value.data : null);
const [saving, setSaving] = useState<boolean>(false);
const [saveFailed, setSaveFailed] = useState<boolean>(false);
@ -50,9 +57,14 @@ const GuildOverviewDisplay: FC<GuildOverviewDisplayProps> = (props: GuildOvervie
if (iconBuff === savedIconBuff) setIconBuff(iconResource.value.data);
setSavedIconBuff(iconResource.value.data);
}
}, [iconBuff, iconResource, savedIconBuff]);
// this effect is only for when the iconResource changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [iconResource]);
const changes = useMemo(() => name !== savedName || iconBuff?.toString('hex') !== savedIconBuff?.toString('hex'), [name, savedName, iconBuff, savedIconBuff]);
const changes = useMemo(
() => name !== savedName || iconBuff?.toString('hex') !== savedIconBuff?.toString('hex'),
[name, savedName, iconBuff, savedIconBuff],
);
const errorMessage = useMemo(() => {
if (isFailed(iconResource)) return 'Unable to load icon';
@ -95,7 +107,6 @@ const GuildOverviewDisplay: FC<GuildOverviewDisplayProps> = (props: GuildOvervie
if (iconBuff && iconBuff?.toString('hex') !== savedIconBuff?.toString('hex')) {
// save icon
try {
LOG.debug('saving icon');
await guild.requestSetGuildIcon(iconBuff);
setSavedIconBuff(iconBuff);
} catch (e: unknown) {
@ -110,11 +121,25 @@ const GuildOverviewDisplay: FC<GuildOverviewDisplayProps> = (props: GuildOvervie
}, [name, iconBuff, errorMessage, saving, savedName, savedIconBuff, guild]);
return (
<Display changes={changes} resetChanges={resetChanges} saveChanges={saveChanges} saving={saving} saveFailed={saveFailed} errorMessage={errorMessage} infoMessage={infoMessage}>
<Display
changes={changes}
resetChanges={resetChanges}
saveChanges={saveChanges}
saving={saving}
saveFailed={saveFailed}
errorMessage={errorMessage}
infoMessage={infoMessage}
>
<div className="overview">
<div className="metadata">
<div className="icon">
<ImageEditInput maxSize={Globals.MAX_GUILD_ICON_SIZE} value={iconBuff} setValue={setIconBuff} setValid={setIconInputValid} setMessage={setIconInputMessage} />
<ImageEditInput
maxSize={Globals.MAX_GUILD_ICON_SIZE}
value={iconBuff}
setValue={setIconBuff}
setValid={setIconInputValid}
setMessage={setIconInputMessage}
/>
</div>
<div className="name">
<TextInput

View File

@ -4,13 +4,45 @@ import Logger from '../../../../logger/logger';
const LOG = Logger.create(__filename, electronConsole);
import { ReactNode, useEffect } from 'react';
import { atom, atomFamily, GetRecoilValue, RecoilState, RecoilValue, RecoilValueReadOnly, selector, selectorFamily, useRecoilState, useSetRecoilState } from 'recoil';
import { Changes, Channel, GuildMetadata, Member, Message, Resource, ShouldNeverHappenError, Token } from '../../data-types';
import {
atom,
atomFamily,
GetRecoilValue,
RecoilState,
RecoilValue,
RecoilValueReadOnly,
selector,
selectorFamily,
useRecoilState,
useSetRecoilState,
} from 'recoil';
import {
Changes,
Channel,
GuildMetadata,
Member,
Message,
Resource,
ShouldNeverHappenError,
Token,
} from '../../data-types';
import CombinedGuild from '../../guild-combined';
import GuildsManager from '../../guilds-manager';
import ElementsUtil from './elements-util';
import Globals from '../../globals';
import { createFailedValue, createLoadedValue, DEF_PENDED_VALUE, DEF_UNLOADED_SCROLLING_VALUE, DEF_UNLOADED_VALUE, isFailed, isLoaded, isPended, isUnload, LoadableValue, LoadableValueScrolling } from './loadables';
import {
createFailedValue,
createLoadedValue,
DEF_PENDED_VALUE,
DEF_UNLOADED_SCROLLING_VALUE,
DEF_UNLOADED_VALUE,
isFailed,
isLoaded,
isPended,
isUnload,
LoadableValue,
LoadableValueScrolling,
} from './loadables';
import {
applyChangedElements,
applyIfLoaded,
@ -52,18 +84,22 @@ export const guildMetaState = atomFamily<LoadableValue<GuildMetadata>, number>({
key: 'guildMetaState',
default: DEF_UNLOADED_VALUE,
effects_UNSTABLE: (guildId: number) => [
guildDataSubscriptionLoadableSingleEffect(guildId, async (guild: CombinedGuild) => await guild.fetchMetadata(), {
updatedEvent: {
name: 'update-metadata',
argsMap: (newMeta: GuildMetadata) => newMeta,
applyFunc: applyIfLoaded,
guildDataSubscriptionLoadableSingleEffect(
guildId,
async (guild: CombinedGuild) => await guild.fetchMetadata(),
{
updatedEvent: {
name: 'update-metadata',
argsMap: (newMeta: GuildMetadata) => newMeta,
applyFunc: applyIfLoaded,
},
conflictEvent: {
name: 'conflict-metadata',
argsMap: (_changesType, _oldMeta, newMeta: GuildMetadata) => newMeta,
applyFunc: applyIfLoaded,
},
},
conflictEvent: {
name: 'conflict-metadata',
argsMap: (_changesType, _oldMeta, newMeta: GuildMetadata) => newMeta,
applyFunc: applyIfLoaded,
},
}),
),
],
dangerouslyAllowMutability: true,
});
@ -118,28 +154,32 @@ const guildMembersState = atomFamily<LoadableValue<Member[]>, number>({
key: 'guildMembersState',
default: DEF_UNLOADED_VALUE,
effects_UNSTABLE: (guildId: number) => [
guildDataSubscriptionLoadableMultipleEffect(guildId, async (guild: CombinedGuild) => await guild.fetchMembers(), {
newEvent: {
name: 'new-members',
argsMap: (newMembers: Member[]) => newMembers,
applyFunc: applyListFuncIfLoaded(applyNewElements, Member.sortForList),
guildDataSubscriptionLoadableMultipleEffect(
guildId,
async (guild: CombinedGuild) => await guild.fetchMembers(),
{
newEvent: {
name: 'new-members',
argsMap: (newMembers: Member[]) => newMembers,
applyFunc: applyListFuncIfLoaded(applyNewElements, Member.sortForList),
},
updatedEvent: {
name: 'update-members',
argsMap: (newMembers: Member[]) => newMembers,
applyFunc: applyListFuncIfLoaded(applyUpdatedElements, Member.sortForList),
},
removedEvent: {
name: 'remove-members',
argsMap: (newMembers: Member[]) => newMembers,
applyFunc: applyListFuncIfLoaded(applyRemovedElements, Member.sortForList),
},
conflictEvent: {
name: 'conflict-members',
argsMap: (_changeType, changes: Changes<Member>) => changes,
applyFunc: applyListFuncIfLoaded(applyChangedElements, Member.sortForList),
},
},
updatedEvent: {
name: 'update-members',
argsMap: (newMembers: Member[]) => newMembers,
applyFunc: applyListFuncIfLoaded(applyUpdatedElements, Member.sortForList),
},
removedEvent: {
name: 'remove-members',
argsMap: (newMembers: Member[]) => newMembers,
applyFunc: applyListFuncIfLoaded(applyRemovedElements, Member.sortForList),
},
conflictEvent: {
name: 'conflict-members',
argsMap: (_changeType, changes: Changes<Member>) => changes,
applyFunc: applyListFuncIfLoaded(applyChangedElements, Member.sortForList),
},
}),
),
],
dangerouslyAllowMutability: true,
});
@ -174,28 +214,32 @@ const guildChannelsState = atomFamily<LoadableValue<Channel[]>, number>({
key: 'guildChannelsState',
default: DEF_UNLOADED_VALUE,
effects_UNSTABLE: (guildId: number) => [
guildDataSubscriptionLoadableMultipleEffect(guildId, async (guild: CombinedGuild) => await guild.fetchChannels(), {
newEvent: {
name: 'new-channels',
argsMap: (newChannels: Channel[]) => newChannels,
applyFunc: applyListFuncIfLoaded(applyNewElements, Channel.sortByIndex),
guildDataSubscriptionLoadableMultipleEffect(
guildId,
async (guild: CombinedGuild) => await guild.fetchChannels(),
{
newEvent: {
name: 'new-channels',
argsMap: (newChannels: Channel[]) => newChannels,
applyFunc: applyListFuncIfLoaded(applyNewElements, Channel.sortByIndex),
},
updatedEvent: {
name: 'update-channels',
argsMap: (updatedChannels: Channel[]) => updatedChannels,
applyFunc: applyListFuncIfLoaded(applyUpdatedElements, Channel.sortByIndex),
},
removedEvent: {
name: 'remove-channels',
argsMap: (removedChannels: Channel[]) => removedChannels,
applyFunc: applyListFuncIfLoaded(applyRemovedElements, Channel.sortByIndex),
},
conflictEvent: {
name: 'conflict-channels',
argsMap: (_changeType, changes: Changes<Channel>) => changes,
applyFunc: applyListFuncIfLoaded(applyChangedElements, Channel.sortByIndex),
},
},
updatedEvent: {
name: 'update-channels',
argsMap: (updatedChannels: Channel[]) => updatedChannels,
applyFunc: applyListFuncIfLoaded(applyUpdatedElements, Channel.sortByIndex),
},
removedEvent: {
name: 'remove-channels',
argsMap: (removedChannels: Channel[]) => removedChannels,
applyFunc: applyListFuncIfLoaded(applyRemovedElements, Channel.sortByIndex),
},
conflictEvent: {
name: 'conflict-channels',
argsMap: (_changeType, changes: Changes<Channel>) => changes,
applyFunc: applyListFuncIfLoaded(applyChangedElements, Channel.sortByIndex),
},
}),
),
],
dangerouslyAllowMutability: true,
});
@ -226,16 +270,22 @@ const guildActiveChannelState = selectorFamily<LoadableValue<Channel>, number>({
dangerouslyAllowMutability: true,
});
const guildChannelMessagesState = atomFamily<LoadableValueScrolling<Message[], Message>, { guildId: number; channelId: string }>({
const guildChannelMessagesState = atomFamily<
LoadableValueScrolling<Message[], Message>,
{ guildId: number; channelId: string }
>({
key: 'guildChannelMessagesState',
default: DEF_UNLOADED_SCROLLING_VALUE,
effects_UNSTABLE: ({ guildId, channelId }) => [
multipleScrollingGuildSubscriptionEffect(
guildId,
{
fetchBottomFunc: async (guild: CombinedGuild, count: number) => await guild.fetchMessagesRecent(channelId, count),
fetchAboveFunc: async (guild: CombinedGuild, reference: Message, count: number) => await guild.fetchMessagesBefore(channelId, reference._order, count),
fetchBelowFunc: async (guild: CombinedGuild, reference: Message, count: number) => await guild.fetchMessagesAfter(channelId, reference._order, count),
fetchBottomFunc: async (guild: CombinedGuild, count: number) =>
await guild.fetchMessagesRecent(channelId, count),
fetchAboveFunc: async (guild: CombinedGuild, reference: Message, count: number) =>
await guild.fetchMessagesBefore(channelId, reference._order, count),
fetchBelowFunc: async (guild: CombinedGuild, reference: Message, count: number) =>
await guild.fetchMessagesAfter(channelId, reference._order, count),
},
Globals.MESSAGES_PER_REQUEST,
Globals.MAX_CURRENT_MESSAGES,
@ -243,18 +293,33 @@ const guildChannelMessagesState = atomFamily<LoadableValueScrolling<Message[], M
{
newEvent: {
name: 'new-messages',
argsMap: (newMessages: Message[]) => newMessages.filter(message => message.channel.id === channelId),
applyFunc: applyListScrollingFuncIfLoaded(applyNewElements, Message.sortOrder, Globals.MAX_CURRENT_MESSAGES),
argsMap: (newMessages: Message[]) =>
newMessages.filter(message => message.channel.id === channelId),
applyFunc: applyListScrollingFuncIfLoaded(
applyNewElements,
Message.sortOrder,
Globals.MAX_CURRENT_MESSAGES,
),
},
updatedEvent: {
name: 'update-messages',
argsMap: (updatedMessages: Message[]) => updatedMessages.filter(message => message.channel.id === channelId),
applyFunc: applyListScrollingFuncIfLoaded(applyUpdatedElements, Message.sortOrder, Globals.MAX_CURRENT_MESSAGES),
argsMap: (updatedMessages: Message[]) =>
updatedMessages.filter(message => message.channel.id === channelId),
applyFunc: applyListScrollingFuncIfLoaded(
applyUpdatedElements,
Message.sortOrder,
Globals.MAX_CURRENT_MESSAGES,
),
},
removedEvent: {
name: 'remove-messages',
argsMap: (removedMessages: Message[]) => removedMessages.filter(message => message.channel.id === channelId),
applyFunc: applyListScrollingFuncIfLoaded(applyRemovedElements, Message.sortOrder, Globals.MAX_CURRENT_MESSAGES),
argsMap: (removedMessages: Message[]) =>
removedMessages.filter(message => message.channel.id === channelId),
applyFunc: applyListScrollingFuncIfLoaded(
applyRemovedElements,
Message.sortOrder,
Globals.MAX_CURRENT_MESSAGES,
),
},
conflictEvent: {
name: 'conflict-messages',
@ -263,7 +328,11 @@ const guildChannelMessagesState = atomFamily<LoadableValueScrolling<Message[], M
updated: changes.updated.filter(change => change.newDataPoint.channel.id === channelId),
deleted: changes.deleted.filter(message => message.channel.id === channelId),
}),
applyFunc: applyListScrollingFuncIfLoaded(applyChangedElements, Message.sortOrder, Globals.MAX_CURRENT_MESSAGES),
applyFunc: applyListScrollingFuncIfLoaded(
applyChangedElements,
Message.sortOrder,
Globals.MAX_CURRENT_MESSAGES,
),
},
},
),
@ -275,28 +344,32 @@ export const guildTokensState = atomFamily<LoadableValue<Token[]>, number>({
key: 'guildTokensState',
default: DEF_UNLOADED_VALUE,
effects_UNSTABLE: (guildId: number) => [
guildDataSubscriptionLoadableMultipleEffect(guildId, async (guild: CombinedGuild) => await guild.fetchTokens(), {
newEvent: {
name: 'new-tokens',
argsMap: (newTokens: Token[]) => newTokens,
applyFunc: applyListFuncIfLoaded(applyNewElements, Token.sortRecentCreatedFirst),
guildDataSubscriptionLoadableMultipleEffect(
guildId,
async (guild: CombinedGuild) => await guild.fetchTokens(),
{
newEvent: {
name: 'new-tokens',
argsMap: (newTokens: Token[]) => newTokens,
applyFunc: applyListFuncIfLoaded(applyNewElements, Token.sortFurthestExpiresFirst),
},
updatedEvent: {
name: 'update-tokens',
argsMap: (updatedTokens: Token[]) => updatedTokens,
applyFunc: applyListFuncIfLoaded(applyUpdatedElements, Token.sortFurthestExpiresFirst),
},
removedEvent: {
name: 'remove-tokens',
argsMap: (removedTokens: Token[]) => removedTokens,
applyFunc: applyListFuncIfLoaded(applyRemovedElements, Token.sortFurthestExpiresFirst),
},
conflictEvent: {
name: 'conflict-tokens',
argsMap: (_changeType, changes: Changes<Token>) => changes,
applyFunc: applyListFuncIfLoaded(applyChangedElements, Token.sortFurthestExpiresFirst),
},
},
updatedEvent: {
name: 'update-tokens',
argsMap: (updatedTokens: Token[]) => updatedTokens,
applyFunc: applyListFuncIfLoaded(applyUpdatedElements, Token.sortRecentCreatedFirst),
},
removedEvent: {
name: 'remove-tokens',
argsMap: (removedTokens: Token[]) => removedTokens,
applyFunc: applyListFuncIfLoaded(applyRemovedElements, Token.sortRecentCreatedFirst),
},
conflictEvent: {
name: 'conflict-tokens',
argsMap: (_changeType, changes: Changes<Token>) => changes,
applyFunc: applyListFuncIfLoaded(applyChangedElements, Token.sortRecentCreatedFirst),
},
}),
),
],
});
@ -327,7 +400,11 @@ function createCurrentGuildStateGetter<T>(subSelectorFamily: (guildId: number) =
return value;
};
}
function createCurrentGuildHardValueWithParamStateGetter<T, P>(subSelectorFamily: (param: P) => RecoilValueReadOnly<T> | RecoilState<T>, guildIdToParam: (guildId: number) => P, unloadedValue: T) {
function createCurrentGuildHardValueWithParamStateGetter<T, P>(
subSelectorFamily: (param: P) => RecoilValueReadOnly<T> | RecoilState<T>,
guildIdToParam: (guildId: number) => P,
unloadedValue: T,
) {
return ({ get }: { get: GetRecoilValue }) => {
// use the unloaded value if the current guild hasn't been selected yet or doesn't exist
const currGuildId = get(currGuildIdState);
@ -338,7 +415,9 @@ function createCurrentGuildHardValueWithParamStateGetter<T, P>(subSelectorFamily
return value;
};
}
function createCurrentGuildLoadableStateGetter<T>(subSelectorFamily: (guildId: number) => RecoilValueReadOnly<LoadableValue<T>> | RecoilState<LoadableValue<T>>) {
function createCurrentGuildLoadableStateGetter<T>(
subSelectorFamily: (guildId: number) => RecoilValueReadOnly<LoadableValue<T>> | RecoilState<LoadableValue<T>>,
) {
return ({ get }: { get: GetRecoilValue }) => {
// use the unloaded value if the current guild hasn't been selected yet or doesn't exist
const currGuildId = get(currGuildIdState);
@ -348,7 +427,9 @@ function createCurrentGuildLoadableStateGetter<T>(subSelectorFamily: (guildId: n
return value;
};
}
function createCurrentGuildActiveChannelLoadableScrollingStateGetter<T>(subSelectorFamily: (param: { guildId: number; channelId: string }) => RecoilState<LoadableValueScrolling<T[], T>>) {
function createCurrentGuildActiveChannelLoadableScrollingStateGetter<T>(
subSelectorFamily: (param: { guildId: number; channelId: string }) => RecoilState<LoadableValueScrolling<T[], T>>,
) {
return ({ get }: { get: GetRecoilValue }) => {
// use the unloaded value if the current guild / current guild active channel hasn't been selected yet or doesn't exist
const guildId = get(currGuildIdState);
@ -360,7 +441,10 @@ function createCurrentGuildActiveChannelLoadableScrollingStateGetter<T>(subSelec
return value;
};
}
function createCurrentGuildLoadableWithParamStateGetter<T, P>(subSelectorFamily: (param: P) => RecoilValueReadOnly<LoadableValue<T>> | RecoilState<LoadableValue<T>>, guildIdToParam: (guildId: number) => P) {
function createCurrentGuildLoadableWithParamStateGetter<T, P>(
subSelectorFamily: (param: P) => RecoilValueReadOnly<LoadableValue<T>> | RecoilState<LoadableValue<T>>,
guildIdToParam: (guildId: number) => P,
) {
return ({ get }: { get: GetRecoilValue }) => {
// use the unloaded value if the current guild hasn't been selected yet or doesn't exist
const currGuildId = get(currGuildIdState);
@ -388,13 +472,22 @@ export const currGuildMetaState = selector<LoadableValue<GuildMetadata>>({
});
export const currGuildResourceState = selectorFamily<LoadableValue<Resource>, string | null>({
key: 'currGuildResourceState',
get: (resourceId: string | null) => createCurrentGuildLoadableWithParamStateGetter(guildResourceState, (guildId: number) => ({ guildId, resourceId })),
get: (resourceId: string | null) =>
createCurrentGuildLoadableWithParamStateGetter(guildResourceState, (guildId: number) => ({
guildId,
resourceId,
})),
dangerouslyAllowMutability: true,
});
export const currGuildResourceSoftImgSrcState = selectorFamily<string, string | null>({
key: 'currGuildResourceSoftImgSrcState',
get: (resourceId: string | null) => createCurrentGuildHardValueWithParamStateGetter(guildResourceSoftImgSrcState, (guildId: number) => ({ guildId, resourceId }), './img/loading.svg'),
get: (resourceId: string | null) =>
createCurrentGuildHardValueWithParamStateGetter(
guildResourceSoftImgSrcState,
(guildId: number) => ({ guildId, resourceId }),
'./img/loading.svg',
),
dangerouslyAllowMutability: true,
});

View File

@ -54,7 +54,9 @@ export default class DB {
}
static async getAllGuilds(): Promise<any[]> {
const result = await db.query('SELECT * FROM "guilds" LEFT JOIN "guilds_meta" ON "guilds"."id"="guilds_meta"."id"');
const result = await db.query(
'SELECT * FROM "guilds" LEFT JOIN "guilds_meta" ON "guilds"."id"="guilds_meta"."id"',
);
return result.rows;
}
@ -63,9 +65,15 @@ export default class DB {
const der = publicKey.export({ type: 'spki', format: 'der' });
// eslint-disable-next-line newline-per-chained-call
LOG.silly(`searching for public key (der hash: ${LOG.inspect(crypto.createHash('sha256').update(der).digest().toString('hex'))})`);
LOG.silly(
`searching for public key (der hash: ${LOG.inspect(
crypto.createHash('sha256').update(der).digest().toString('hex'),
)})`,
);
const result = await db.query('SELECT "id" AS member_id, "guild_id" FROM "members" WHERE "public_key"=$1', [der]);
const result = await db.query('SELECT "id" AS member_id, "guild_id" FROM "members" WHERE "public_key"=$1', [
der,
]);
if (result.rows.length !== 1) {
throw new Error('unable to find member with specified public key');
@ -116,7 +124,10 @@ export default class DB {
}
static async getMember(guildId: string, memberId: string): Promise<any> {
const result = await db.query('SELECT * FROM "members_with_roles" WHERE "guild_id"=$1 AND "id"=$2', [guildId, memberId]);
const result = await db.query('SELECT * FROM "members_with_roles" WHERE "guild_id"=$1 AND "id"=$2', [
guildId,
memberId,
]);
if (result.rows.length !== 1) {
throw new Error('unable to get member');
}
@ -124,14 +135,19 @@ export default class DB {
}
static async getChannels(guildId: string): Promise<any[]> {
const result = await db.query('SELECT "id", "index", "name", "flavor_text" FROM "channels" WHERE "guild_id"=$1 ORDER BY "index"', [guildId]);
const result = await db.query(
'SELECT "id", "index", "name", "flavor_text" FROM "channels" WHERE "guild_id"=$1 ORDER BY "index"',
[guildId],
);
return result.rows;
}
static async createChannel(guildId: string, name: string, flavorText: string): Promise<any> {
let channel = null;
await DB.queueTransaction(async () => {
const indexResult = await db.query('SELECT MAX("index") AS max_index FROM "channels" WHERE "guild_id"=$1', [guildId]);
const indexResult = await db.query('SELECT MAX("index") AS max_index FROM "channels" WHERE "guild_id"=$1', [
guildId,
]);
if (indexResult.rows.length !== 1) {
throw new Error('invalid index result');
}
@ -189,7 +205,12 @@ export default class DB {
return result.rows;
}
static async getMessagesBefore(guildId: string, channelId: string, messageOrderId: string, number: number): Promise<any[]> {
static async getMessagesBefore(
guildId: string,
channelId: string,
messageOrderId: string,
number: number,
): Promise<any[]> {
const result = await db.query(
`
SELECT * FROM (
@ -215,7 +236,12 @@ export default class DB {
return result.rows;
}
static async getMessagesAfter(guildId: string, channelId: string, messageOrderId: string, number: number): Promise<any[]> {
static async getMessagesAfter(
guildId: string,
channelId: string,
messageOrderId: string,
number: number,
): Promise<any[]> {
const result = await db.query(
`
SELECT
@ -313,7 +339,18 @@ export default class DB {
, "resource_preview_id"
, "order"
`,
[id, guildId, channelId, memberId, text, resourceId, resourceName, resourceWidth, resourceHeight, resourcePreviewId],
[
id,
guildId,
channelId,
memberId,
text,
resourceId,
resourceName,
resourceWidth,
resourceHeight,
resourcePreviewId,
],
);
if (result.rows.length !== 1) {
@ -425,7 +462,7 @@ export default class DB {
static async assignRoleToMember(guildId: string, roleId: string, memberId: string): Promise<void> {
const existsResult = await db.query(
`
SELECT COUNT(*) AS c FROM "member_roles" WHERE
SELECT COUNT(*)::integer AS c FROM "member_roles" WHERE
"role_id" = $1
AND "guild_id" = $2
AND "member_id" = $3
@ -458,7 +495,7 @@ export default class DB {
static async revokeRoleFromMember(guildId: string, roleId: string, memberId: string): Promise<void> {
const existsResult = await db.query(
`
SELECT COUNT(*) AS c FROM "member_roles" WHERE
SELECT COUNT(*)::integer AS c FROM "member_roles" WHERE
"role_id" = $1
AND "guild_id" = $2
AND "member_id" = $3
@ -490,7 +527,7 @@ export default class DB {
static async assignRolePrivilege(guildId: string, roleId: string, privilege: string): Promise<void> {
const existsResult = await db.query(
`
SELECT COUNT(*) AS c FROM "role_privileges" WHERE
SELECT COUNT(*)::integer AS c FROM "role_privileges" WHERE
"role_id" = $1
AND "guild_id" = $2
AND "privilege" = $3
@ -523,7 +560,7 @@ export default class DB {
static async revokeRolePrivilege(roleId: string, guildId: string, privilege: string): Promise<void> {
const existsResult = await db.query(
`
SELECT COUNT(*) AS c FROM "role_privileges" WHERE
SELECT COUNT(*)::integer AS c FROM "role_privileges" WHERE
"role_id" = $1
AND "guild_id" = $2
AND "privilege" = $3
@ -555,7 +592,7 @@ export default class DB {
static async hasPrivilege(guildId: string, memberId: string, privilege: string): Promise<boolean> {
const result = await db.query(
`
SELECT COUNT(*) AS c FROM
SELECT COUNT(*)::integer AS c FROM
member_roles
, role_privileges
WHERE
@ -612,28 +649,42 @@ export default class DB {
}
static async isTokenReal(token: string): Promise<boolean> {
const result = await db.query(`SELECT COUNT(*) AS c FROM "tokens" WHERE "token"=$1`, [token]);
const result = await db.query(`SELECT COUNT(*)::integer AS c FROM "tokens" WHERE "token"=$1`, [token]);
return result.rows[0].c === 1;
}
static async isTokenForGuild(token: string, guildId: string): Promise<boolean> {
const result = await db.query(`SELECT COUNT(*) AS c FROM "tokens" WHERE "token"=$1 AND "guild_id"=$2`, [token, guildId]);
const result = await db.query(
`SELECT COUNT(*)::integer AS c FROM "tokens" WHERE "token"=$1 AND "guild_id"=$2`,
[token, guildId],
);
return result.rows[0].c === 1;
}
static async isTokenTaken(token: string): Promise<boolean> {
const result = await db.query(`SELECT COUNT(*) AS c FROM "tokens" WHERE "token"=$1 AND "member_id" IS NOT NULL`, [token]);
const result = await db.query(
`SELECT COUNT(*)::integer AS c FROM "tokens" WHERE "token"=$1 AND "member_id" IS NOT NULL`,
[token],
);
return result.rows[0].c === 1;
}
static async isTokenActive(token: string): Promise<boolean> {
const result = await db.query(`SELECT COUNT(*) AS c FROM "tokens" WHERE "token"=$1 AND "member_id" IS NULL AND "expires">NOW()`, [token]);
const result = await db.query(
`SELECT COUNT(*)::integer AS c FROM "tokens" WHERE "token"=$1 AND "member_id" IS NULL AND "expires">NOW()`,
[token],
);
return result.rows[0].c === 1;
}
// registers a user for with a token
// NOTE: Tokens are unique across guilds so they can be used to get the guildId
static async registerWithToken(token: string, derBuff: Buffer, displayName: string, avatarBuff: Buffer): Promise<{ guildId: string; memberId: string }> {
static async registerWithToken(
token: string,
derBuff: Buffer,
displayName: string,
avatarBuff: Buffer,
): Promise<{ guildId: string; memberId: string }> {
// insert avatar as resource
let result: { memberId: string; guildId: string } | null = null;
await DB.queueTransaction(async () => {
@ -679,7 +730,10 @@ export default class DB {
}
static async revokeToken(guildId: string, token: string): Promise<any> {
const result = await db.query('DELETE FROM "tokens" WHERE "guild_id"=$1 AND "token"=$2 RETURNING *', [guildId, token]);
const result = await db.query('DELETE FROM "tokens" WHERE "guild_id"=$1 AND "token"=$2 RETURNING *', [
guildId,
token,
]);
if (result.rows.length !== 1) {
throw new Error('unable to remove token');
}

View File

@ -139,10 +139,17 @@ function bindEvent(
}
} catch (e) {
if (e instanceof SignatureError) {
LOG.warn(`c#${client.id}: ${name} request does not match expected signature`, { signature, args, msg: e.message });
LOG.warn(`c#${client.id}: ${name} request does not match expected signature`, {
signature,
args,
msg: e.message,
});
// do not respond to requests with invalid signatures
} else if (e instanceof EventError) {
LOG.warn(`c#${client.id}: ${e.message}${e.extended_message ? ' / ' + e.extended_message : ''}`, e.cause);
LOG.warn(
`c#${client.id}: ${e.message}${e.extended_message ? ' / ' + e.extended_message : ''}`,
e.cause,
);
respond(e.message);
} else {
LOG.error('caught unhandled error', e);
@ -163,40 +170,47 @@ function bindRegistrationEvents(io: socketio.Server, client: socketio.Socket): v
* @param respond.errStr Error string if an error, else null
* @param respond.member The member created by the registration
*/
bindEvent(client, null, null, 'register-with-token', ['string', 'buffer', 'string', 'buffer', 'function'], async (token, publicKeyBuff, displayName, avatarBuff, respond) => {
if (displayName.length > MAX_DISPLAY_NAME_LENGTH || displayName.length === 0) {
throw new EventError('invalid display name');
}
if (avatarBuff.length > MAX_AVATAR_SIZE) {
throw new EventError('invalid avatar');
}
bindEvent(
client,
null,
null,
'register-with-token',
['string', 'buffer', 'string', 'buffer', 'function'],
async (token, publicKeyBuff, displayName, avatarBuff, respond) => {
if (displayName.length > MAX_DISPLAY_NAME_LENGTH || displayName.length === 0) {
throw new EventError('invalid display name');
}
if (avatarBuff.length > MAX_AVATAR_SIZE) {
throw new EventError('invalid avatar');
}
const typeResult = await FileType.fromBuffer(avatarBuff);
if (!typeResult || ['image/png', 'image/jpeg', 'image/jpg'].indexOf(typeResult.mime) === -1) {
throw new EventError('invalid avatar mime type');
}
const typeResult = await FileType.fromBuffer(avatarBuff);
if (!typeResult || ['image/png', 'image/jpeg', 'image/jpg'].indexOf(typeResult.mime) === -1) {
throw new EventError('invalid avatar mime type');
}
if (!(await DB.isTokenReal(token))) {
throw new EventError('not a real token');
}
if (await DB.isTokenTaken(token)) {
throw new EventError('token already used');
}
if (!(await DB.isTokenActive(token))) {
throw new EventError('token expired');
}
if (!(await DB.isTokenReal(token))) {
throw new EventError('not a real token');
}
if (await DB.isTokenTaken(token)) {
throw new EventError('token already used');
}
if (!(await DB.isTokenActive(token))) {
throw new EventError('token expired');
}
const { guildId, memberId } = await DB.registerWithToken(token, publicKeyBuff, displayName, avatarBuff);
const { guildId, memberId } = await DB.registerWithToken(token, publicKeyBuff, displayName, avatarBuff);
const member = await DB.getMember(guildId, memberId);
const meta = await DB.getGuild(guildId);
const member = await DB.getMember(guildId, memberId);
const meta = await DB.getGuild(guildId);
LOG.info(`c#${client.id}: registered with t#${token} as u#${member.id} / ${member.display_name}`);
LOG.info(`c#${client.id}: registered with t#${token} as u#${member.id} / ${member.display_name}`);
respond(null, member, meta);
respond(null, member, meta);
io.to(guildId).emit('new-member', member);
});
io.to(guildId).emit('new-member', member);
},
);
}
function bindChallengeVerificationEvents(io: socketio.Server, client: socketio.Socket, identity: IIdentity): void {
@ -294,7 +308,13 @@ function bindChallengeVerificationEvents(io: socketio.Server, client: socketio.S
}
identity.verified = true;
const rooms = [identity.guildId].concat(member && member.privileges ? member.privileges.split(',').map((privilege: string) => guildPrivilegeRoomName(identity.guildId as string, privilege)) : []);
const rooms = [identity.guildId].concat(
member && member.privileges
? member.privileges
.split(',')
.map((privilege: string) => guildPrivilegeRoomName(identity.guildId as string, privilege))
: [],
);
LOG.debug(`c#${client.id} joining ${rooms.join(', ')}`);
client.join(rooms);
@ -311,18 +331,25 @@ function bindAdminEvents(io: socketio.Server, client: socketio.Socket, identity:
* @param respond.errStr Error string if an error, else null
* @param respond.guildMeta The update guild metadata information
*/
bindEvent(client, identity, { verified: true, privileges: ['modify_profile'] }, 'set-name', ['string', 'function'], async (name, respond) => {
if (name.length === 0 || name.length > MAX_GUILD_NAME_LENGTH) throw new EventError('invalid guild name');
if (!identity.guildId) throw new EventError('identity no guildId');
bindEvent(
client,
identity,
{ verified: true, privileges: ['modify_profile'] },
'set-name',
['string', 'function'],
async (name, respond) => {
if (name.length === 0 || name.length > MAX_GUILD_NAME_LENGTH) throw new EventError('invalid guild name');
if (!identity.guildId) throw new EventError('identity no guildId');
LOG.debug(`g#${identity.guildId} u#${identity.memberId} set-name to ${name}`);
LOG.debug(`g#${identity.guildId} u#${identity.memberId} set-name to ${name}`);
const newMeta = await DB.setName(identity.guildId, name);
const newMeta = await DB.setName(identity.guildId, name);
respond(null, newMeta);
respond(null, newMeta);
io.to(identity.guildId).emit('update-metadata', newMeta);
});
io.to(identity.guildId).emit('update-metadata', newMeta);
},
);
/*
* sets the icon of the guild
@ -331,24 +358,31 @@ function bindAdminEvents(io: socketio.Server, client: socketio.Socket, identity:
* @param respond.errStr Error string if an error, else null
* @param respond.guildMeta The update guild metadata information
*/
bindEvent(client, identity, { verified: true, privileges: ['modify_profile'] }, 'set-icon', ['buffer', 'function'], async (iconBuff, respond) => {
if (iconBuff.length === 0 || iconBuff.length > MAX_ICON_SIZE) throw new EventError('invalid guild icon');
if (!identity.guildId) throw new EventError('identity no guildId');
bindEvent(
client,
identity,
{ verified: true, privileges: ['modify_profile'] },
'set-icon',
['buffer', 'function'],
async (iconBuff, respond) => {
if (iconBuff.length === 0 || iconBuff.length > MAX_ICON_SIZE) throw new EventError('invalid guild icon');
if (!identity.guildId) throw new EventError('identity no guildId');
LOG.debug(`g#${identity.guildId} u#${identity.memberId} set-icon`);
LOG.debug(`g#${identity.guildId} u#${identity.memberId} set-icon`);
const typeResult = await FileType.fromBuffer(iconBuff);
if (!typeResult || !['image/png', 'image/jpeg', 'image/jpg'].includes(typeResult.mime)) {
throw new EventError('detected invalid mime type');
}
const typeResult = await FileType.fromBuffer(iconBuff);
if (!typeResult || !['image/png', 'image/jpeg', 'image/jpg'].includes(typeResult.mime)) {
throw new EventError('detected invalid mime type');
}
const iconResourceId = await DB.insertResource(identity.guildId, iconBuff);
const newMeta = await DB.setIcon(identity.guildId, iconResourceId);
const iconResourceId = await DB.insertResource(identity.guildId, iconBuff);
const newMeta = await DB.setIcon(identity.guildId, iconResourceId);
respond(null, newMeta);
respond(null, newMeta);
io.to(identity.guildId).emit('update-metadata', newMeta);
});
io.to(identity.guildId).emit('update-metadata', newMeta);
},
);
/*
* creates a text channel
@ -358,24 +392,34 @@ function bindAdminEvents(io: socketio.Server, client: socketio.Socket, identity:
* @param respond.errStr Error string if an error, else null
* @param respond.channel The created channel
*/
bindEvent(client, identity, { verified: true, privileges: ['modify_channels'] }, 'create-text-channel', ['string', 'string?', 'function'], async (name, flavorText, respond) => {
if (!identity.guildId) throw new EventError('identity no guildId');
bindEvent(
client,
identity,
{ verified: true, privileges: ['modify_channels'] },
'create-text-channel',
['string', 'string?', 'function'],
async (name, flavorText, respond) => {
if (!identity.guildId) throw new EventError('identity no guildId');
flavorText = flavorText || null;
flavorText = flavorText || null;
if (name.length === 0 || name.length > MAX_CHANNEL_NAME_LENGTH || !/^[A-Za-z0-9-]+$/.exec(name)) {
throw new EventError('invalid channel name');
}
if (flavorText !== null && (flavorText.length === 0 || flavorText.length > MAX_CHANNEL_FLAVOR_TEXT_LENGTH)) {
throw new EventError('invalid flavor text');
}
if (name.length === 0 || name.length > MAX_CHANNEL_NAME_LENGTH || !/^[A-Za-z0-9-]+$/.exec(name)) {
throw new EventError('invalid channel name');
}
if (
flavorText !== null &&
(flavorText.length === 0 || flavorText.length > MAX_CHANNEL_FLAVOR_TEXT_LENGTH)
) {
throw new EventError('invalid flavor text');
}
const newChannel = await DB.createChannel(identity.guildId, name, flavorText);
const newChannel = await DB.createChannel(identity.guildId, name, flavorText);
respond(null, newChannel);
respond(null, newChannel);
io.to(identity.guildId).emit('new-channel', newChannel);
});
io.to(identity.guildId).emit('new-channel', newChannel);
},
);
/*
* updates a channel
@ -386,24 +430,34 @@ function bindAdminEvents(io: socketio.Server, client: socketio.Socket, identity:
* @param respond.errStr Error string if an error, else null
* @param respond.channel Updated channel
*/
bindEvent(client, identity, { verified: true, privileges: ['modify_channels'] }, 'update-channel', ['string', 'string', 'string?', 'function'], async (channelId, name, flavorText, respond) => {
if (!identity.guildId) throw new EventError('identity no guildId');
bindEvent(
client,
identity,
{ verified: true, privileges: ['modify_channels'] },
'update-channel',
['string', 'string', 'string?', 'function'],
async (channelId, name, flavorText, respond) => {
if (!identity.guildId) throw new EventError('identity no guildId');
flavorText = flavorText || null;
flavorText = flavorText || null;
if (name.length === 0 || name.length > MAX_CHANNEL_NAME_LENGTH || !/^[A-Za-z0-9-]+$/.exec(name)) {
throw new EventError('invalid name');
}
if (flavorText !== null && (flavorText.length === 0 || flavorText.length > MAX_CHANNEL_FLAVOR_TEXT_LENGTH)) {
throw new EventError('invalid flavor text');
}
if (name.length === 0 || name.length > MAX_CHANNEL_NAME_LENGTH || !/^[A-Za-z0-9-]+$/.exec(name)) {
throw new EventError('invalid name');
}
if (
flavorText !== null &&
(flavorText.length === 0 || flavorText.length > MAX_CHANNEL_FLAVOR_TEXT_LENGTH)
) {
throw new EventError('invalid flavor text');
}
const updatedChannel = await DB.updateChannel(identity.guildId, channelId, name, flavorText);
const updatedChannel = await DB.updateChannel(identity.guildId, channelId, name, flavorText);
respond(null, updatedChannel);
respond(null, updatedChannel);
io.to(identity.guildId).emit('update-channel', updatedChannel);
});
io.to(identity.guildId).emit('update-channel', updatedChannel);
},
);
/*
* gets the list of tokens that have been or are available to use
@ -411,12 +465,19 @@ function bindAdminEvents(io: socketio.Server, client: socketio.Socket, identity:
* @param respond.errStr Error string if an error, else null
* @param respond.tokens The list of outstanding tokens in form of [ { id, token, member_id, created, expires } ]
*/
bindEvent(client, identity, { verified: true, privileges: ['modify_members'] }, 'fetch-tokens', ['function'], async respond => {
if (!identity.guildId) throw new EventError('identity no guildId');
LOG.debug(`u#${identity.memberId}: fetching tokens`);
const tokens = await DB.getTokens(identity.guildId);
respond(null, tokens);
});
bindEvent(
client,
identity,
{ verified: true, privileges: ['modify_members'] },
'fetch-tokens',
['function'],
async respond => {
if (!identity.guildId) throw new EventError('identity no guildId');
LOG.debug(`u#${identity.memberId}: fetching tokens`);
const tokens = await DB.getTokens(identity.guildId);
respond(null, tokens);
},
);
/*
* creates a token to allow a new member to register
@ -424,16 +485,23 @@ function bindAdminEvents(io: socketio.Server, client: socketio.Socket, identity:
* @param respond.errStr Error string if an error, else null
* @param respond.token The token that was created in form [ { id, token, member_id, created, expires } ]
*/
bindEvent(client, identity, { verified: true, privileges: ['modify_members'] }, 'create-token', ['string?', 'function'], async (expiresAfter, respond) => {
if (!identity.guildId) throw new EventError('identity no guildId');
bindEvent(
client,
identity,
{ verified: true, privileges: ['modify_members'] },
'create-token',
['string?', 'function'],
async (expiresAfter, respond) => {
if (!identity.guildId) throw new EventError('identity no guildId');
LOG.debug(`u#${identity.memberId}: creating token with expiresAfter=${expiresAfter}`);
const token = await DB.createToken(identity.guildId, expiresAfter);
respond(null, token);
LOG.debug(`u#${identity.memberId}: creating token with expiresAfter=${expiresAfter}`);
const token = await DB.createToken(identity.guildId, expiresAfter);
respond(null, token);
const targetRoom = guildPrivilegeRoomName(identity.guildId as string, 'modify_members');
io.in(targetRoom).emit('create-token', token);
});
const targetRoom = guildPrivilegeRoomName(identity.guildId as string, 'modify_members');
io.in(targetRoom).emit('create-token', token);
},
);
/*
* revokes a token so that it can no longer be used
@ -441,24 +509,31 @@ function bindAdminEvents(io: socketio.Server, client: socketio.Socket, identity:
* @param respond function(errStr) as a socket.io response function
* @param respond.errStr Error string if an error, else null
*/
bindEvent(client, identity, { verified: true, privileges: ['modify_members'] }, 'revoke-token', ['string', 'function'], async (token, respond) => {
if (!identity.guildId) throw new EventError('identity no guildId');
if (!(await DB.isTokenReal(token))) {
throw new EventError('fake token');
}
if (!(await DB.isTokenForGuild(token, identity.guildId))) {
throw new EventError('fake token', undefined, 'tried to revoke token for a different guild');
}
if (await DB.isTokenTaken(token)) {
throw new EventError('token already taken');
}
LOG.debug(`u#${identity.memberId}: revoking t#${token}`);
const revokedToken = await DB.revokeToken(identity.guildId, token);
respond(null);
bindEvent(
client,
identity,
{ verified: true, privileges: ['modify_members'] },
'revoke-token',
['string', 'function'],
async (token, respond) => {
if (!identity.guildId) throw new EventError('identity no guildId');
if (!(await DB.isTokenReal(token))) {
throw new EventError('fake token', undefined, 'tokenId: ' + token);
}
if (!(await DB.isTokenForGuild(token, identity.guildId))) {
throw new EventError('fake token', undefined, 'tried to revoke token for a different guild');
}
if (await DB.isTokenTaken(token)) {
throw new EventError('token already taken');
}
LOG.debug(`u#${identity.memberId}: revoking t#${token}`);
const revokedToken = await DB.revokeToken(identity.guildId, token);
respond(null);
const targetRoom = guildPrivilegeRoomName(identity.guildId as string, 'modify_members');
io.in(targetRoom).emit('revoke-token', revokedToken);
});
const targetRoom = guildPrivilegeRoomName(identity.guildId as string, 'modify_members');
io.in(targetRoom).emit('revoke-token', revokedToken);
},
);
}
function bindActionEvents(io: socketio.Server, client: socketio.Socket, identity: IIdentity) {
@ -470,19 +545,30 @@ function bindActionEvents(io: socketio.Server, client: socketio.Socket, identity
* @param respond.errStr Error string if an error, else null
* @param respond.message Sent message data object parsed from PostgreSQL
*/
bindEvent(client, identity, { verified: true }, 'send-message', ['string', 'string', 'function'], async (channelId, text, respond) => {
if (text.length > MAX_TEXT_MESSAGE_LENGTH) throw new EventError('message is too long');
if (!identity.guildId) throw new EventError('identity no guildId');
if (!identity.memberId) throw new EventError('identity no memberId');
bindEvent(
client,
identity,
{ verified: true },
'send-message',
['string', 'string', 'function'],
async (channelId, text, respond) => {
if (text.length > MAX_TEXT_MESSAGE_LENGTH) throw new EventError('message is too long');
if (!identity.guildId) throw new EventError('identity no guildId');
if (!identity.memberId) throw new EventError('identity no memberId');
const message = await DB.insertMessage(identity.guildId, channelId, identity.memberId, text);
const message = await DB.insertMessage(identity.guildId, channelId, identity.memberId, text);
LOG.info(`m#${message.id} ch#${message.channel_id} s@${formatDate(message.sent_dtg)} u#${message.member_id}: ${message.text}`);
LOG.info(
`m#${message.id} ch#${message.channel_id} s@${formatDate(message.sent_dtg)} u#${message.member_id}: ${
message.text
}`,
);
respond(null, message);
respond(null, message);
io.to(identity.guildId).emit('new-message', message);
});
io.to(identity.guildId).emit('new-message', message);
},
);
/*
* send a message with a resource to a channel
@ -494,79 +580,103 @@ function bindActionEvents(io: socketio.Server, client: socketio.Socket, identity
* @param respond.errStr Error string if an error, else null
* @param respond.message Sent message data object parsed from PostgreSQL
*/
bindEvent(client, identity, { verified: true }, 'send-message-with-resource', ['string', 'string?', 'buffer', 'string', 'function'], async (channelId, text, resource, resourceName, respond) => {
if (text && text.length > MAX_TEXT_MESSAGE_LENGTH) throw new EventError('message is too long');
if (resource.length > MAX_RESOURCE_SIZE) throw new EventError('resource is too large');
bindEvent(
client,
identity,
{ verified: true },
'send-message-with-resource',
['string', 'string?', 'buffer', 'string', 'function'],
async (channelId, text, resource, resourceName, respond) => {
if (text && text.length > MAX_TEXT_MESSAGE_LENGTH) throw new EventError('message is too long');
if (resource.length > MAX_RESOURCE_SIZE) throw new EventError('resource is too large');
if (!identity.guildId) throw new EventError('identity no guildId');
if (!identity.memberId) throw new EventError('identity no memberId');
if (!identity.guildId) throw new EventError('identity no guildId');
if (!identity.memberId) throw new EventError('identity no memberId');
resourceName = resourceName.trim();
resourceName = resourceName.replace(/[^A-Za-z0-9 .]/g, '_'); // only alphanumerics for file names
resourceName = resourceName.trim();
resourceName = resourceName.replace(/[^A-Za-z0-9 .]/g, '_'); // only alphanumerics for file names
LOG.info(`u#${identity.memberId}: resource message with resource of size: ${resource.length} bytes`);
LOG.info(`u#${identity.memberId}: resource message with resource of size: ${resource.length} bytes`);
// try to get the dimensions of the resource if it is an image so that we can scale it down for the preview
const fileType = (await FileType.fromBuffer(resource)) ?? { mime: null, ext: null };
// try to get the dimensions of the resource if it is an image so that we can scale it down for the preview
const fileType = (await FileType.fromBuffer(resource)) ?? { mime: null, ext: null };
const dimensions: { width: number | null; height: number | null } = { width: null, height: null };
const dimensions: { width: number | null; height: number | null } = { width: null, height: null };
switch (fileType.mime) {
case 'image/png':
case 'image/jpeg':
case 'image/gif': {
const size = sizeOf(resource);
dimensions.width = size.width ?? null;
dimensions.height = size.height ?? null;
break;
}
}
// pre-scale the image (optimized for the electron discord client's max-dimensions of 400x300)
let resourcePreview: Buffer | null = null;
if (dimensions.width !== null && dimensions.height !== null) {
let previewWidth = dimensions.width;
let previewHeight = dimensions.height;
if (previewWidth > 400) {
const scale = 400 / previewWidth;
previewWidth *= scale;
previewHeight *= scale;
}
if (previewHeight > 300) {
const scale = 300 / previewHeight;
previewWidth *= scale;
previewHeight *= scale;
}
// jpeg for image compression and trash visuals B)
resourcePreview = await sharp(resource).resize(Math.floor(previewWidth), Math.floor(previewHeight)).jpeg().toBuffer();
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let message: any | null = null;
try {
await DB.queueTransaction(async () => {
if (!identity.guildId) throw new EventError('identity no guildId');
if (!identity.memberId) throw new EventError('identity no guildId');
let resourcePreviewId: string | null = null;
if (resourcePreview) {
resourcePreviewId = await DB.insertResource(identity.guildId, resourcePreview);
switch (fileType.mime) {
case 'image/png':
case 'image/jpeg':
case 'image/gif': {
const size = sizeOf(resource);
dimensions.width = size.width ?? null;
dimensions.height = size.height ?? null;
break;
}
}
const resourceId = await DB.insertResource(identity.guildId, resource);
// pre-scale the image (optimized for the electron discord client's max-dimensions of 400x300)
let resourcePreview: Buffer | null = null;
if (dimensions.width !== null && dimensions.height !== null) {
let previewWidth = dimensions.width;
let previewHeight = dimensions.height;
if (previewWidth > 400) {
const scale = 400 / previewWidth;
previewWidth *= scale;
previewHeight *= scale;
}
if (previewHeight > 300) {
const scale = 300 / previewHeight;
previewWidth *= scale;
previewHeight *= scale;
}
// jpeg for image compression and trash visuals B)
resourcePreview = await sharp(resource)
.resize(Math.floor(previewWidth), Math.floor(previewHeight))
.jpeg()
.toBuffer();
}
message = await DB.insertMessageWithResource(identity.guildId, channelId, identity.memberId, text, resourceId, resourceName, dimensions.width, dimensions.height, resourcePreviewId);
});
} catch (e) {
throw new EventError('unable to insert message with resource', e as Error);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let message: any | null = null;
try {
await DB.queueTransaction(async () => {
if (!identity.guildId) throw new EventError('identity no guildId');
if (!identity.memberId) throw new EventError('identity no guildId');
LOG.info(`m#${message.id} ch#${message.channel_id} s@${formatDate(message.sent_dtg)} u#${message.member_id}: ${message.text ? message.text + ' / ' : ''}${message.resource_name}`);
let resourcePreviewId: string | null = null;
if (resourcePreview) {
resourcePreviewId = await DB.insertResource(identity.guildId, resourcePreview);
}
respond(null, message);
const resourceId = await DB.insertResource(identity.guildId, resource);
io.to(identity.guildId).emit('new-message', message);
});
message = await DB.insertMessageWithResource(
identity.guildId,
channelId,
identity.memberId,
text,
resourceId,
resourceName,
dimensions.width,
dimensions.height,
resourcePreviewId,
);
});
} catch (e) {
throw new EventError('unable to insert message with resource', e as Error);
}
LOG.info(
`m#${message.id} ch#${message.channel_id} s@${formatDate(message.sent_dtg)} u#${message.member_id}: ${
message.text ? message.text + ' / ' : ''
}${message.resource_name}`,
);
respond(null, message);
io.to(identity.guildId).emit('new-message', message);
},
);
/*
* set the status for a verified member
@ -600,20 +710,28 @@ function bindActionEvents(io: socketio.Server, client: socketio.Socket, identity
* @param respond.errStr Error string if an error, else null
* @param respond.updatedMember The updated member object (note: an update-member event will be emitted)
*/
bindEvent(client, identity, { verified: true }, 'set-display-name', ['string', 'function'], async (displayName, respond) => {
if (displayName.length > MAX_DISPLAY_NAME_LENGTH || displayName.length === 0) throw new EventError('invalid display name');
if (!identity.guildId) throw new EventError('identity no guildId');
if (!identity.memberId) throw new EventError('identity no memberId');
bindEvent(
client,
identity,
{ verified: true },
'set-display-name',
['string', 'function'],
async (displayName, respond) => {
if (displayName.length > MAX_DISPLAY_NAME_LENGTH || displayName.length === 0)
throw new EventError('invalid display name');
if (!identity.guildId) throw new EventError('identity no guildId');
if (!identity.memberId) throw new EventError('identity no memberId');
LOG.info(`u#${identity.memberId}: setting display name to ${displayName}`);
LOG.info(`u#${identity.memberId}: setting display name to ${displayName}`);
await DB.setMemberDisplayName(identity.guildId, identity.memberId, displayName);
const updated = await DB.getMember(identity.guildId, identity.memberId);
await DB.setMemberDisplayName(identity.guildId, identity.memberId, displayName);
const updated = await DB.getMember(identity.guildId, identity.memberId);
respond(null, updated);
respond(null, updated);
io.to(identity.guildId).emit('update-member', updated);
});
io.to(identity.guildId).emit('update-member', updated);
},
);
/*
* set the status for a verified member
@ -622,31 +740,38 @@ function bindActionEvents(io: socketio.Server, client: socketio.Socket, identity
* @param respond.errStr Error string if an error, else null
* @param respond.updatedMember The updated member object (note: an update-member event will be emitted)
*/
bindEvent(client, identity, { verified: true }, 'set-avatar', ['buffer', 'function'], async (avatarBuff, respond) => {
if (!identity.guildId) throw new EventError('identity no guildId');
if (!identity.memberId) throw new EventError('identity no memberId');
bindEvent(
client,
identity,
{ verified: true },
'set-avatar',
['buffer', 'function'],
async (avatarBuff, respond) => {
if (!identity.guildId) throw new EventError('identity no guildId');
if (!identity.memberId) throw new EventError('identity no memberId');
if (avatarBuff.length > MAX_AVATAR_SIZE) {
LOG.warn(`c#${client.id}: avatar too large`);
respond('buffer too large');
return;
}
if (avatarBuff.length > MAX_AVATAR_SIZE) {
LOG.warn(`c#${client.id}: avatar too large`);
respond('buffer too large');
return;
}
const typeResult = (await FileType.fromBuffer(avatarBuff)) ?? { mime: null, ext: null };
if ((['image/png', 'image/jpeg', 'image/jpg'] as (string | null)[]).indexOf(typeResult.mime) === -1) {
throw new EventError('invalid avatar buffer');
}
const typeResult = (await FileType.fromBuffer(avatarBuff)) ?? { mime: null, ext: null };
if ((['image/png', 'image/jpeg', 'image/jpg'] as (string | null)[]).indexOf(typeResult.mime) === -1) {
throw new EventError('invalid avatar buffer');
}
LOG.info(`u#${identity.memberId}: uploaded new avatar`);
LOG.info(`u#${identity.memberId}: uploaded new avatar`);
const resourceId = await DB.insertResource(identity.guildId, avatarBuff);
await DB.setMemberAvatarResourceId(identity.guildId, identity.memberId, resourceId);
const updated = await DB.getMember(identity.guildId, identity.memberId);
const resourceId = await DB.insertResource(identity.guildId, avatarBuff);
await DB.setMemberAvatarResourceId(identity.guildId, identity.memberId, resourceId);
const updated = await DB.getMember(identity.guildId, identity.memberId);
respond(null, updated);
respond(null, updated);
io.to(identity.guildId).emit('update-member', updated);
});
io.to(identity.guildId).emit('update-member', updated);
},
);
}
function bindFetchEvents(client: socketio.Socket, identity: IIdentity): void {
@ -688,13 +813,20 @@ function bindFetchEvents(client: socketio.Socket, identity: IIdentity): void {
* @param respond.errStr Error string if an error, else null
* @param respond.messages List of message data objects in the channel from PostgreSQL
*/
bindEvent(client, identity, { verified: true }, 'fetch-messages-recent', ['string', 'number', 'function'], async (channelId, number, respond) => {
if (!identity.guildId) throw new EventError('identity no guildId');
if (!identity.memberId) throw new EventError('identity no memberId');
LOG.info(`u#${identity.memberId}: fetching recent messages for ch#${channelId} number: ${number}`);
const messages = await DB.getMessagesRecent(identity.guildId, channelId, number);
respond(null, messages);
});
bindEvent(
client,
identity,
{ verified: true },
'fetch-messages-recent',
['string', 'number', 'function'],
async (channelId, number, respond) => {
if (!identity.guildId) throw new EventError('identity no guildId');
if (!identity.memberId) throw new EventError('identity no memberId');
LOG.info(`u#${identity.memberId}: fetching recent messages for ch#${channelId} number: ${number}`);
const messages = await DB.getMessagesRecent(identity.guildId, channelId, number);
respond(null, messages);
},
);
/*
* client.on('fetch-messages-before', (channelId, messageId, number, respond(errStr, messages)))
@ -706,13 +838,22 @@ function bindFetchEvents(client: socketio.Socket, identity: IIdentity): void {
* @param respond.errStr Error string if an error, else null
* @param respond.messages List of message data objects in the channel from PostgreSQL
*/
bindEvent(client, identity, { verified: true }, 'fetch-messages-before', ['string', 'string', 'number', 'function'], async (channelId, messageOrderId, number, respond) => {
if (!identity.guildId) throw new EventError('identity no guildId');
if (!identity.memberId) throw new EventError('identity no memberId');
LOG.info(`u#${identity.memberId}: fetching messages before ch#${channelId} mo#${messageOrderId} number: ${number}`);
const messages = await DB.getMessagesBefore(identity.guildId, channelId, messageOrderId, number);
respond(null, messages);
});
bindEvent(
client,
identity,
{ verified: true },
'fetch-messages-before',
['string', 'string', 'number', 'function'],
async (channelId, messageOrderId, number, respond) => {
if (!identity.guildId) throw new EventError('identity no guildId');
if (!identity.memberId) throw new EventError('identity no memberId');
LOG.info(
`u#${identity.memberId}: fetching messages before ch#${channelId} mo#${messageOrderId} number: ${number}`,
);
const messages = await DB.getMessagesBefore(identity.guildId, channelId, messageOrderId, number);
respond(null, messages);
},
);
/*
* fetch messages coming after the specified message
@ -723,13 +864,22 @@ function bindFetchEvents(client: socketio.Socket, identity: IIdentity): void {
* @param respond.errStr Error string if an error, else null
* @param respond.messages List of message data objects in the channel from PostgreSQL
*/
bindEvent(client, identity, { verified: true }, 'fetch-messages-after', ['string', 'string', 'number', 'function'], async (channelId, messageOrderId, number, respond) => {
if (!identity.guildId) throw new EventError('identity no guildId');
if (!identity.memberId) throw new EventError('identity no memberId');
LOG.info(`u#${identity.memberId}: fetching messages after ch#${channelId} mo#${messageOrderId} number: ${number}`);
const messages = await DB.getMessagesAfter(identity.guildId, channelId, messageOrderId, number);
respond(null, messages);
});
bindEvent(
client,
identity,
{ verified: true },
'fetch-messages-after',
['string', 'string', 'number', 'function'],
async (channelId, messageOrderId, number, respond) => {
if (!identity.guildId) throw new EventError('identity no guildId');
if (!identity.memberId) throw new EventError('identity no memberId');
LOG.info(
`u#${identity.memberId}: fetching messages after ch#${channelId} mo#${messageOrderId} number: ${number}`,
);
const messages = await DB.getMessagesAfter(identity.guildId, channelId, messageOrderId, number);
respond(null, messages);
},
);
/*
* @param resourceId The id of the resource to fetch
@ -737,13 +887,20 @@ function bindFetchEvents(client: socketio.Socket, identity: IIdentity): void {
* @param respond.errStr Error string if an error, else null
* @param respond.resource Resource object { id, guild_id, hash, data }
*/
bindEvent(client, identity, { verified: true }, 'fetch-resource', ['string', 'function'], async (resourceId, respond) => {
if (!identity.guildId) throw new EventError('identity no guildId');
if (!identity.memberId) throw new EventError('identity no memberId');
LOG.info(`u#${identity.memberId}: fetching r#${resourceId}`);
const resource = await DB.getResource(identity.guildId, resourceId);
respond(null, resource);
});
bindEvent(
client,
identity,
{ verified: true },
'fetch-resource',
['string', 'function'],
async (resourceId, respond) => {
if (!identity.guildId) throw new EventError('identity no guildId');
if (!identity.memberId) throw new EventError('identity no memberId');
LOG.info(`u#${identity.memberId}: fetching r#${resourceId}`);
const resource = await DB.getResource(identity.guildId, resourceId);
respond(null, resource);
},
);
/*
* fetch members in the guild