diff --git a/src/client/webapp/elements/displays/display-guild-invites.tsx b/src/client/webapp/elements/displays/display-guild-invites.tsx index 4c6c8fe..2e5d25c 100644 --- a/src/client/webapp/elements/displays/display-guild-invites.tsx +++ b/src/client/webapp/elements/displays/display-guild-invites.tsx @@ -3,7 +3,7 @@ const electronConsole = electronRemote.getGlobal('console') as Console; import Logger from '../../../../logger/logger'; const LOG = Logger.create(__filename, electronConsole); -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { FC } from 'react'; import CombinedGuild from '../../guild-combined'; import Display from '../components/display'; @@ -49,8 +49,19 @@ const GuildInvitesDisplay: FC = (props: GuildInvitesDi } }, [ expiresFromNowText ]); + const [ tokenResult, tokenError, tokenButtonText, tokenButtonShaking, tokenButtonCallback ] = ReactHelper.useAsyncButtonSubscription( + async () => await guild.requestDoCreateToken(expiresFromNowText === 'never' ? null : expiresFromNowText), + { start: 'Create Token', pending: 'Creating...', error: 'Try Again', done: 'Create Token' }, + [ guild, expiresFromNowText ] + ); + + const createToken = useCallback(async () => { + await guild.requestDoCreateToken(expiresFromNowText); // note: the text, NOT the duration. The server uses PostgreSQL interval conversion + }, [ expiresFromNowText ]); + const errorMessage = useMemo(() => { if (guildMetaError) return 'Unable to load guild metadata'; + if (tokenError) return 'Unable to create token'; return null; }, [ guildMetaError ]); @@ -97,7 +108,7 @@ const GuildInvitesDisplay: FC = (props: GuildInvitesDi { value: '1 month', display: 'a Month' }, { value: 'never', display: 'Never' }, ]} /> -
+
new EventEmitter>(), []); const onFetch = useCallback((fetchValue: T[] | null) => { + if (fetchValue) fetchValue.sort(sortFunc); setValue(fetchValue); setFetchError(null); events.emit('fetch'); diff --git a/src/client/webapp/elements/require/react-helper.ts b/src/client/webapp/elements/require/react-helper.ts index b197f54..9aab990 100644 --- a/src/client/webapp/elements/require/react-helper.ts +++ b/src/client/webapp/elements/require/react-helper.ts @@ -3,9 +3,10 @@ const electronConsole = electronRemote.getGlobal('console') as Console; import Logger from '../../../../logger/logger'; const LOG = Logger.create(__filename, electronConsole); -import { DependencyList, useEffect, useRef, useState } from "react"; +import { DependencyList, useCallback, useEffect, useMemo, useRef, useState } from "react"; import ReactDOMServer from "react-dom/server"; import { ShouldNeverHappenError } from "../../data-types"; +import Util from '../../util'; // Helper function so we can use JSX before fully committing to React @@ -51,4 +52,54 @@ export default class ReactHelper { return [ value, error ]; } + + static useAsyncButtonSubscription( + actionFunc: () => Promise, + stateText: { start: string, pending: string, error: string, done: string }, + deps: DependencyList + ): [ result: T | null, error: unknown | null, text: string, shaking: boolean, callback: () => void ] { + const isMounted = useRef(false); + useEffect(() => { + isMounted.current = true; + return () => { isMounted.current = false; } + }); + + const [ result, setResult ] = useState(null); + const [ error, setError ] = useState(null); + + const [ pending, setPending ] = useState(false); + const [ complete, setComplete ] = useState(false); + const [ shaking, setShaking ] = useState(false); + + const text = useMemo(() => { + if (error) return stateText.error; + if (pending) return stateText.pending; + if (complete) return stateText.done; + return stateText.start; + }, [ error, pending, complete ]); + + const callback = useCallback(async () => { + if (pending) return; + setPending(true); + try { + const value = await actionFunc(); + if (!isMounted.current) return; + setResult(value); + setComplete(true); + setError(null); + setPending(false); + } catch (e: unknown) { + LOG.error('unable to perform async button subscription'); + if (!isMounted.current) return; + setError(e); + setShaking(true); + await Util.sleep(400); + if (!isMounted.current) return; + setShaking(false); + setPending(false); + } + }, [ ...deps, pending ]); + + return [ result, error, text, shaking, callback ]; + } } diff --git a/src/client/webapp/guild-combined.ts b/src/client/webapp/guild-combined.ts index 348bba3..6508fb1 100644 --- a/src/client/webapp/guild-combined.ts +++ b/src/client/webapp/guild-combined.ts @@ -384,13 +384,16 @@ export default class CombinedGuild extends EventEmitter { await this.socketGuild.requestSetGuildIcon(guildIcon); } - async requestDoUpdateChannel(channelId: string, name: string, flavorText: string | null): Promise { - await this.socketGuild.requestDoUpdateChannel(channelId, name, flavorText); + async requestDoUpdateChannel(channelId: string, name: string, flavorText: string | null): Promise { + return await this.socketGuild.requestDoUpdateChannel(channelId, name, flavorText); } - async requestDoCreateChannel(name: string, flavorText: string | null): Promise { - await this.socketGuild.requestDoCreateChannel(name, flavorText); + async requestDoCreateChannel(name: string, flavorText: string | null): Promise { + return await this.socketGuild.requestDoCreateChannel(name, flavorText); } async requestDoRevokeToken(token: string): Promise { await this.socketGuild.requestDoRevokeToken(token); } + async requestDoCreateToken(expiresAfter: string | null): Promise { + return await this.socketGuild.requestDoCreateToken(expiresAfter); + } } diff --git a/src/client/webapp/guild-socket.ts b/src/client/webapp/guild-socket.ts index d8cc0ba..7ba73a8 100644 --- a/src/client/webapp/guild-socket.ts +++ b/src/client/webapp/guild-socket.ts @@ -69,8 +69,7 @@ export default class SocketGuild extends EventEmitter implements As this.emit('update-resource', updatedResource); }); - // TODO: The server does not emit new-token - this.socket.on('new-token', async (newDataToken: any) => { + this.socket.on('create-token', async (newDataToken: any) => { const newToken = Token.fromDBData(newDataToken); this.emit('new-tokens', [ newToken ]); }); @@ -182,13 +181,19 @@ export default class SocketGuild extends EventEmitter implements As async requestSetGuildIcon(guildIcon: Buffer): Promise { await this.queryOnceVerified(Globals.DEFAULT_SOCKET_TIMEOUT, 'set-icon', guildIcon); } - async requestDoUpdateChannel(channelId: string, name: string, flavorText: string | null): Promise { - const _changedChannel = await this.queryOnceVerified(Globals.DEFAULT_SOCKET_TIMEOUT, 'update-channel', channelId, name, flavorText); + async requestDoUpdateChannel(channelId: string, name: string, flavorText: string | null): Promise { + const dataChangedChannel = await this.queryOnceVerified(Globals.DEFAULT_SOCKET_TIMEOUT, 'update-channel', channelId, name, flavorText); + return Channel.fromDBData(dataChangedChannel); } - async requestDoCreateChannel(name: string, flavorText: string | null): Promise { - const _newChannel = await this.queryOnceVerified(Globals.DEFAULT_SOCKET_TIMEOUT, 'create-text-channel', name, flavorText); + async requestDoCreateChannel(name: string, flavorText: string | null): Promise { + const dataNewChannel = await this.queryOnceVerified(Globals.DEFAULT_SOCKET_TIMEOUT, 'create-text-channel', name, flavorText); + return Channel.fromDBData(dataNewChannel); } async requestDoRevokeToken(token: string): Promise { await this.queryOnceVerified(Globals.DEFAULT_SOCKET_TIMEOUT, 'revoke-token', token); } + async requestDoCreateToken(expiresAfter: string | null): Promise { + const dataToken = await this.queryOnceVerified(Globals.DEFAULT_SOCKET_TIMEOUT, 'create-token', expiresAfter); + return Token.fromDBData(dataToken); + } } diff --git a/src/client/webapp/guild-types.ts b/src/client/webapp/guild-types.ts index d3442bb..b19f348 100644 --- a/src/client/webapp/guild-types.ts +++ b/src/client/webapp/guild-types.ts @@ -49,9 +49,10 @@ export interface AsyncRequestable { requestSetAvatar(avatar: Buffer): Promise; requestSetGuildName(guildName: string): Promise; requestSetGuildIcon(guildIcon: Buffer): Promise; - requestDoUpdateChannel(channelId: string, name: string, flavorText: string | null): Promise; - requestDoCreateChannel(name: string, flavorText: string | null): Promise; + requestDoUpdateChannel(channelId: string, name: string, flavorText: string | null): Promise; + requestDoCreateChannel(name: string, flavorText: string | null): Promise; requestDoRevokeToken(token: string): Promise; + requestDoCreateToken(expiresAfter: string | null): Promise; } export type Requestable = AsyncRequestable; diff --git a/src/server/db.ts b/src/server/db.ts index 25c9484..39612e1 100644 --- a/src/server/db.ts +++ b/src/server/db.ts @@ -519,18 +519,18 @@ export default class DB { static async getTokens(guildId: string): Promise { const result = await db.query(` - SELECT "token", "member_id", "created", "expires" FROM tokens + SELECT "id", "token", "member_id", "created", "expires" FROM tokens WHERE "guild_id"=$1 `, [ guildId]); return result.rows; } //insert into tokens (guild_id, expires) VALUES ('226b3e9e-5220-4205-bf5b-6738b9b3bb39', NOW() + '7 days'::interval) RETURNING "token", "expires"; - static async createToken(guildId: string, expiresAfter: string): Promise { + static async createToken(guildId: string, expiresAfter: string | null): Promise { const result = await db.query(` INSERT INTO "tokens" ("guild_id", "expires") - VALUES ($1, NOW() + $2::interval) - RETURNING "token", "expires" + VALUES ($1, CASE WHEN $2::text IS NULL THEN NULL ELSE NOW() + $2::interval END) + RETURNING "id", "token", "member_id", "created", "expires" `, [ guildId, expiresAfter ]); if (result.rows.length != 1) { throw new Error('unable to insert a token'); diff --git a/src/server/server-controller.ts b/src/server/server-controller.ts index 5396ebd..3e26d96 100644 --- a/src/server/server-controller.ts +++ b/src/server/server-controller.ts @@ -38,7 +38,7 @@ interface IChecks { } function formatDate(date: Date): string { - return moment(date).format('YYYY-MM-DD HH:mm:ss'); + return moment(date).format('YYYY-MM-DD HH:mm:ss'); } const connected: IIdentity[] = []; @@ -47,23 +47,23 @@ class EventError extends Error { public cause: Error | undefined; public extended_message: string | undefined; - constructor(message: string, cause?: Error, extended_message?: string) { - super(message); - this.name = 'EventError'; - this.cause = cause; - this.extended_message = extended_message; - } + constructor(message: string, cause?: Error, extended_message?: string) { + super(message); + this.name = 'EventError'; + this.cause = cause; + this.extended_message = extended_message; + } } class SignatureError extends Error { - constructor(message: string) { - super(message); - this.name = 'SignatureError'; - } + constructor(message: string) { + super(message); + this.name = 'SignatureError'; + } } -function guildPrivilegeName(guildId: string, privilege: string) { - return guildId + '&' + privilege; +function guildPrivilegeRoomName(guildId: string, privilege: string) { + return guildId + '&' + privilege; } function bindEvent( @@ -75,798 +75,820 @@ function bindEvent( // eslint-disable-next-line @typescript-eslint/no-explicit-any handler: ((...args: any[]) => Promise) ) { - client.on(name, async (...args) => { - // eslint-disable-next-line @typescript-eslint/no-empty-function - let respond = (_result: string) => {}; - try { - if (signature.length != args.length) { - throw new SignatureError('invalid length'); - } - for (let i = 0; i < signature.length; ++i) { - const signatureToken = signature[i] as string; - const arg = args[i] ?? null; - if (signatureToken == 'buffer') { - if (!Buffer.isBuffer(arg)) { - throw new SignatureError('invalid types (buffer expected)'); - } - } else if (signatureToken.endsWith('?')) { - if (arg !== null && typeof arg !== signatureToken.slice(0, -1)) { - throw new SignatureError('invalid types (nullable)'); - } - } else { - if (typeof arg !== signatureToken) { - throw new SignatureError('invalid types (not nullable)'); - } - } - } - if (signature[signature.length - 1] === 'function') { - respond = args[args.length - 1]; - } - if (checks && checks.verified && (!identity || !identity.verified)) { + client.on(name, async (...args) => { + // eslint-disable-next-line @typescript-eslint/no-empty-function + let respond = (_result: string) => {}; + try { + if (signature.length != args.length) { + throw new SignatureError('invalid length'); + } + for (let i = 0; i < signature.length; ++i) { + const signatureToken = signature[i] as string; + const arg = args[i] ?? null; + if (signatureToken == 'buffer') { + if (!Buffer.isBuffer(arg)) { + throw new SignatureError('invalid types (buffer expected)'); + } + } else if (signatureToken.endsWith('?')) { + if (arg !== null && typeof arg !== signatureToken.slice(0, -1)) { + throw new SignatureError('invalid types (nullable)'); + } + } else { + if (typeof arg !== signatureToken) { + throw new SignatureError('invalid types (not nullable)'); + } + } + } + if (signature[signature.length - 1] === 'function') { + respond = args[args.length - 1]; + } + if (checks && checks.verified && (!identity || !identity.verified)) { throw new EventError('not verified'); - } - if (checks && checks.privileges) { - for (const privilege of checks.privileges) { - if (!identity || !identity.guildId || !identity.memberId) { - throw new EventError('not authorized'); - } - let hasPrivilege: boolean; - try { - hasPrivilege = await DB.hasPrivilege(identity.guildId, identity.memberId, privilege); - } catch (e: unknown) { - throw new EventError('not authorized', e as Error, 'unable to check privilege'); - } - if (!hasPrivilege) { - throw new EventError('not authorized'); - } - } - } - //LOG.debug(`c#${client.id}: ${name}`); - try { - await handler(...args); - } catch (e) { - if (e instanceof EventError) { - throw e; - } else { - throw new EventError(`unable to handle ${name} request`, e as Error); - } - } - } catch (e) { - if (e instanceof SignatureError) { - 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); - respond(e.message); - } else { - LOG.error('caught unhandled error', e); - } - } - - }) + } + if (checks && checks.privileges) { + for (const privilege of checks.privileges) { + if (!identity || !identity.guildId || !identity.memberId) { + throw new EventError('not authorized'); + } + let hasPrivilege: boolean; + try { + hasPrivilege = await DB.hasPrivilege(identity.guildId, identity.memberId, privilege); + } catch (e: unknown) { + throw new EventError('not authorized', e as Error, 'unable to check privilege'); + } + if (!hasPrivilege) { + throw new EventError('not authorized'); + } + } + } + //LOG.debug(`c#${client.id}: ${name}`); + try { + await handler(...args); + } catch (e) { + if (e instanceof EventError) { + throw e; + } else { + throw new EventError(`unable to handle ${name} request`, e as Error); + } + } + } catch (e) { + if (e instanceof SignatureError) { + 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); + respond(e.message); + } else { + LOG.error('caught unhandled error', e); + } + } + + }) } function bindRegistrationEvents(io: socketio.Server, client: socketio.Socket): void { // non-identity events - /* - * Register for a guild with a token - * @param token The token sent in the .cordis file - * @param publicKeyBuff The client's public key - * @param displayName The client's display name - * @param avatarBuff The client's avatar image - * @param respond function(errStr, member) as a socket.io response function - * @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'); - } - - 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'); - } + /* + * Register for a guild with a token + * @param token The token sent in the .cordis file + * @param publicKeyBuff The client's public key + * @param displayName The client's display name + * @param avatarBuff The client's avatar image + * @param respond function(errStr, member) as a socket.io response function + * @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'); + } + + 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'); + } - 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 { - identity.memberId = null; - identity.guildId = null; - identity.publicKey = null; - identity.challenge = null; - identity.verified = false; + identity.memberId = null; + identity.guildId = null; + identity.publicKey = null; + identity.challenge = null; + identity.verified = false; - /* - * Request a challenge for login - * @param publicKeyBuff The public key encoded with der/spki - * @param respond function(err, challenge) as a socket.io response function - * @param respond.errStr Error string if an error, else null - * @param respond.algo Signature algorithm to use - * @param respond.type Signature response type to use - * @param respond.challenge Challenge buffer to sign generated by crypto.randomBytes - */ - bindEvent( - client, null, null, - 'challenge', [ 'buffer', 'function' ], - async (publicKeyBuff, respond) => { - identity.verified = false; - identity.memberId = null; - identity.guildId = null; + /* + * Request a challenge for login + * @param publicKeyBuff The public key encoded with der/spki + * @param respond function(err, challenge) as a socket.io response function + * @param respond.errStr Error string if an error, else null + * @param respond.algo Signature algorithm to use + * @param respond.type Signature response type to use + * @param respond.challenge Challenge buffer to sign generated by crypto.randomBytes + */ + bindEvent( + client, null, null, + 'challenge', [ 'buffer', 'function' ], + async (publicKeyBuff, respond) => { + identity.verified = false; + identity.memberId = null; + identity.guildId = null; - identity.publicKey = crypto.createPublicKey({ key: publicKeyBuff, format: 'der', type: 'spki' }); + identity.publicKey = crypto.createPublicKey({ key: publicKeyBuff, format: 'der', type: 'spki' }); - try { - const memberInfo = await DB.getMemberInfo(identity.publicKey); - identity.memberId = memberInfo.member_id; - identity.guildId = memberInfo.guild_id; - } catch (e: unknown) { - // unable to find a member with the specified public key - throw new EventError('unauthorized public key', e as Error); - } + try { + const memberInfo = await DB.getMemberInfo(identity.publicKey); + identity.memberId = memberInfo.member_id; + identity.guildId = memberInfo.guild_id; + } catch (e: unknown) { + // unable to find a member with the specified public key + throw new EventError('unauthorized public key', e as Error); + } - LOG.debug(`c#${client.id}: challenging for u#${identity.memberId}`); - - if (connected.find(i => i.memberId == identity.memberId && i.verified)) { - throw new EventError('member already connected'); - } + LOG.debug(`c#${client.id}: challenging for u#${identity.memberId}`); + + if (connected.find(i => i.memberId == identity.memberId && i.verified)) { + throw new EventError('member already connected'); + } - identity.challenge = crypto.randomBytes(64); - respond(null, 'sha512', 'hex', identity.challenge); - } - ); + identity.challenge = crypto.randomBytes(64); + respond(null, 'sha512', 'hex', identity.challenge); + } + ); - /* - * Verify a signature for login - * @param signature The signed challenge using the client's private key - * @param respond function(errStr, memberId) as a socket.io response function - * @param respond.errStr Error string if an error, else null - * @param respond.memberId The signed in member id - */ - bindEvent( - client, null, null, - 'verify', [ 'string', 'function' ], - async (signature, respond) => { - if (connected.find(i => i.memberId == identity.memberId && i.verified)) { - throw new EventError('member already connected'); - } - - if (!identity.challenge) { - throw new EventError('challenge not requested'); - } + /* + * Verify a signature for login + * @param signature The signed challenge using the client's private key + * @param respond function(errStr, memberId) as a socket.io response function + * @param respond.errStr Error string if an error, else null + * @param respond.memberId The signed in member id + */ + bindEvent( + client, null, null, + 'verify', [ 'string', 'function' ], + async (signature, respond) => { + if (connected.find(i => i.memberId == identity.memberId && i.verified)) { + throw new EventError('member already connected'); + } + + if (!identity.challenge) { + throw new EventError('challenge not requested'); + } - if (!identity.publicKey) { - throw new EventError('publicKey not provided') - } - if (!identity.guildId) { - throw new EventError('guildId not targeted') - } - if (!identity.memberId) { - throw new EventError('memberId not targeted') - } - - const verify = crypto.createVerify('sha512'); - verify.write(identity.challenge); - verify.end(); - - let verified: boolean; - try { - verified = verify.verify(identity.publicKey, signature, 'hex'); - } catch (e: unknown) { - throw new EventError('unable to verify signature', e as Error); - } - - if (!verified) { - throw new EventError('invalid signature'); - } - - let member: any; - try { - await DB.setMemberStatus(identity.guildId, identity.memberId, 'online'); - member = await DB.getMember(identity.guildId, identity.memberId); - io.to(identity.guildId).emit('update-member', member); - } catch (e: unknown) { - LOG.warn('unable to set status for m#' + identity.memberId, e); - // not killing here since this should not be a game ender. Most likely, the SQL server is bad though - } - - identity.verified = true; - const rooms = [ identity.guildId ].concat( - member.privileges.split(',').map((privilege: string) => guildPrivilegeName(identity.guildId as string, privilege)) - ); - LOG.debug(`c#${client.id} joining ${rooms.join(', ')}`); - client.join(rooms); + if (!identity.publicKey) { + throw new EventError('publicKey not provided') + } + if (!identity.guildId) { + throw new EventError('guildId not targeted') + } + if (!identity.memberId) { + throw new EventError('memberId not targeted') + } + + const verify = crypto.createVerify('sha512'); + verify.write(identity.challenge); + verify.end(); + + let verified: boolean; + try { + verified = verify.verify(identity.publicKey, signature, 'hex'); + } catch (e: unknown) { + throw new EventError('unable to verify signature', e as Error); + } + + if (!verified) { + throw new EventError('invalid signature'); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let member: any; + try { + await DB.setMemberStatus(identity.guildId, identity.memberId, 'online'); + member = await DB.getMember(identity.guildId, identity.memberId); + io.to(identity.guildId).emit('update-member', member); + } catch (e: unknown) { + LOG.warn('unable to set status for m#' + identity.memberId, e); + // not killing here since this should not be a game ender. Most likely, the SQL server is bad though + } + + identity.verified = true; + 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); - LOG.info(`c#${client.id}: verified as g#${identity.guildId} u#${identity.memberId}`); - respond(null, identity.memberId); - } - ); + LOG.info(`c#${client.id}: verified as g#${identity.guildId} u#${identity.memberId}`); + respond(null, identity.memberId); + } + ); } function bindAdminEvents(io: socketio.Server, client: socketio.Socket, identity: IIdentity): void { - /* - * Sets the name of the guild - * @param name The new name of the guild - * @param respond function(errStr, guildMeta) as a socket.io response function - * @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'); + /* + * Sets the name of the guild + * @param name The new name of the guild + * @param respond function(errStr, guildMeta) as a socket.io response function + * @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'); - const newMeta = await DB.setName(identity.guildId, name); + const newMeta = await DB.setName(identity.guildId, name); - respond(null, newMeta); + respond(null, newMeta); - io.emit('update-metadata', newMeta); - } - ); + io.emit('update-metadata', newMeta); + } + ); - /* - * Sets the icon of the guild - * @param iconBuff The new icon buffer of the guild - * @param respond function(errStr, guildMeta) as a socket.io response function - * @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'); + /* + * Sets the icon of the guild + * @param iconBuff The new icon buffer of the guild + * @param respond function(errStr, guildMeta) as a socket.io response function + * @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'); - 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.emit('update-metadata', newMeta); - } - ); + io.emit('update-metadata', newMeta); + } + ); - /* - * Creates a text channel - * @param name The name of the new channel - * @param flavorText The flavor text of the new channel (can be null) - * @para repsond function(errStr, channel) as a socket.io response function - * @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'); + /* + * Creates a text channel + * @param name The name of the new channel + * @param flavorText The flavor text of the new channel (can be null) + * @para repsond function(errStr, channel) as a socket.io response function + * @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'); - 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'); - } - - const newChannel = await DB.createChannel(identity.guildId, name, flavorText); + 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); - 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 - * @param channelId The uuid of the channel to update - * @param name The new name of the channel - * @param flavorText The new flavor text of the channel - * @param respond function(errStr, message) as a socket.io response function - * @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'); + /* + * Updates a channel + * @param channelId The uuid of the channel to update + * @param name The new name of the channel + * @param flavorText The new flavor text of the channel + * @param respond function(errStr, message) as a socket.io response function + * @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'); - 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'); - } - - const updatedChannel = await DB.updateChannel(identity.guildId, channelId, name, flavorText); - - respond(null, updatedChannel); + 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); + + 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 - * @param respond function(errStr, message) as a socket.io response function - * @param respond.errStr Error string if an error, else null - * @param respond.tokens The list of outstanding tokens in form of [ { 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); - } - ); + /* + * Gets the list of tokens that have been or are available to use + * @param respond function(errStr, message) as a socket.io response function + * @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); + } + ); - /* - * Revokes a token so that it can no longer be used - * @param token The token to revoke - * @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); + /* + * Creates a token to allow a new member to register + * @param respond function(errStr, message) as a socket.io response function + * @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'); - const targetRoom = guildPrivilegeName(identity.guildId as string, 'modify_members'); - LOG.debug('emitting revoke token to members in ' + targetRoom); - io.in(targetRoom).emit('revoke-token', revokedToken); - } - ); + 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); + } + ); + + /* + * Revokes a token so that it can no longer be used + * @param token The token to revoke + * @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); + + 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) { - /* - * Send a message to a channel - * @param channelId The uuid of the channel to send a message to - * @param text The text of the message - * @param respond function(errStr, message) as a socket.io response function - * @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'); - - 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}`); - - respond(null, message); - - io.to(identity.guildId).emit('new-message', message); - } - ); + /* + * Send a message to a channel + * @param channelId The uuid of the channel to send a message to + * @param text The text of the message + * @param respond function(errStr, message) as a socket.io response function + * @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'); + + 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}`); + + respond(null, message); + + io.to(identity.guildId).emit('new-message', message); + } + ); - /* - * Send a message with a resource to a channel - * @param channelId The uuid of the channel to send a message to - * @param text The text of the message - * @param resource The resource buffer of the message - * @param resourceName The name of the resource - * @param respond function(errStr, message) as a socket.io response function - * @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'); + /* + * Send a message with a resource to a channel + * @param channelId The uuid of the channel to send a message to + * @param text The text of the message + * @param resource The resource buffer of the message + * @param resourceName The name of the resource + * @param respond function(errStr, message) as a socket.io response function + * @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'); - 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 - - 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 }; - - 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'); + 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 + + 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 }; + + 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); - } - - const resourceId = await DB.insertResource(identity.guildId, resource); - - 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); - } - ); + let resourcePreviewId: string | null = null; + if (resourcePreview) { + resourcePreviewId = await DB.insertResource(identity.guildId, resourcePreview); + } + + const resourceId = await DB.insertResource(identity.guildId, resource); + + 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 - * @param status The status for the member to be set to - * @param respond function(errStr, updatedMember) as a socket.io response function - * @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-status', [ 'string', 'function' ], - async (status, respond) => { - if (!identity.guildId) throw new EventError('identity no guildId'); - if (!identity.memberId) throw new EventError('identity no memberId'); + /* + * Set the status for a verified member + * @param status The status for the member to be set to + * @param respond function(errStr, updatedMember) as a socket.io response function + * @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-status', [ 'string', 'function' ], + async (status, respond) => { + if (!identity.guildId) throw new EventError('identity no guildId'); + if (!identity.memberId) throw new EventError('identity no memberId'); - LOG.info(`u#${identity.memberId}: setting status to ${status}`); - - await DB.setMemberStatus(identity.guildId, identity.memberId, status); - const updated = await DB.getMember(identity.guildId, identity.memberId); - - respond(null, updated); - - io.to(identity.guildId).emit('update-member', updated); - } - ); + LOG.info(`u#${identity.memberId}: setting status to ${status}`); + + await DB.setMemberStatus(identity.guildId, identity.memberId, status); + const updated = await DB.getMember(identity.guildId, identity.memberId); + + respond(null, updated); + + io.to(identity.guildId).emit('update-member', updated); + } + ); - /* - * Set the status for a verified member - * @param displayName The new chosen display name - * @param respond function(errStr, displayName) as a socket.io response function - * @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'); - - 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); - - respond(null, updated); - - io.to(identity.guildId).emit('update-member', updated); - } - ); + /* + * Set the status for a verified member + * @param displayName The new chosen display name + * @param respond function(errStr, displayName) as a socket.io response function + * @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'); + + 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); + + respond(null, updated); + + io.to(identity.guildId).emit('update-member', updated); + } + ); - /* - * Set the status for a verified member - * @param avatarBuff The new chosen avatar buffer - * @param respond function(errStr, displayName) as a socket.io response function - * @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'); + /* + * Set the status for a verified member + * @param avatarBuff The new chosen avatar buffer + * @param respond function(errStr, displayName) as a socket.io response function + * @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'); - 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'); - } - - 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); - - respond(null, updated); - - io.to(identity.guildId).emit('update-member', updated); - } - ); + 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`); + + 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); + + io.to(identity.guildId).emit('update-member', updated); + } + ); } function bindFetchEvents(client: socketio.Socket, identity: IIdentity): void { // TODO: consider changing all or some of these to HTTP requests - /* - * Fetch the guild information (name, icon, icon_hash, etc.) - * @param respond function(errStr, guild) as a socket.io response function - * @param respond.errStr Error string if an error, else null - * @param respond.guild The guild information - */ - bindEvent( - client, identity, - { verified: true }, - 'fetch-guild', [ 'function' ], - async (respond) => { - // TODO: Make sure this corresponds with the client - if (!identity.guildId) throw new EventError('identity no guildId'); - if (!identity.memberId) throw new EventError('identity no memberId'); - LOG.info(`u#${identity.memberId}: fetching guild`); - const guild = await DB.getGuild(identity.guildId); - respond(null, guild); - } - ); + /* + * Fetch the guild information (name, icon, icon_hash, etc.) + * @param respond function(errStr, guild) as a socket.io response function + * @param respond.errStr Error string if an error, else null + * @param respond.guild The guild information + */ + bindEvent( + client, identity, + { verified: true }, + 'fetch-guild', [ 'function' ], + async (respond) => { + // TODO: Make sure this corresponds with the client + if (!identity.guildId) throw new EventError('identity no guildId'); + if (!identity.memberId) throw new EventError('identity no memberId'); + LOG.info(`u#${identity.memberId}: fetching guild`); + const guild = await DB.getGuild(identity.guildId); + respond(null, guild); + } + ); - /* - * Fetch a list of channels - * @param respond function(errStr, channels) as a socket.io response function - * @param respond.errStr Error string if an error, else null - * @param respond.channels The list of channels for this guild - */ - bindEvent( - client, identity, - { verified: true }, - 'fetch-channels', [ 'function' ], - async (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 channels`); - const channels = await DB.getChannels(identity.guildId); - respond(null, channels); - } - ); + /* + * Fetch a list of channels + * @param respond function(errStr, channels) as a socket.io response function + * @param respond.errStr Error string if an error, else null + * @param respond.channels The list of channels for this guild + */ + bindEvent( + client, identity, + { verified: true }, + 'fetch-channels', [ 'function' ], + async (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 channels`); + const channels = await DB.getChannels(identity.guildId); + respond(null, channels); + } + ); - /* - * Fetch the most recent messages for a specific channel - * @param channelId The channel uuid of the channel - * @param number The maximum number of messages to get - * @param respond function(errStr, messages) as a socket.io response function - * @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); - } - ); + /* + * Fetch the most recent messages for a specific channel + * @param channelId The channel uuid of the channel + * @param number The maximum number of messages to get + * @param respond function(errStr, messages) as a socket.io response function + * @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); + } + ); - /* - * client.on('fetch-messages-before', (channelId, messageId, number, respond(errStr, messages))) - * Fetch messages coming before the specified message - * @param channelId The channel uuid of the channel - * @param messageId The id of the base message (will not be included in results) - * @param number The maximum number of messages to get - * @param respond function(errStr, messages) as a socket.io response function - * @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, messageId, 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} m#${messageId} number: ${number}`); - const messages = await DB.getMessagesBefore(identity.guildId, channelId, messageId, number); - respond(null, messages); - } - ); + /* + * client.on('fetch-messages-before', (channelId, messageId, number, respond(errStr, messages))) + * Fetch messages coming before the specified message + * @param channelId The channel uuid of the channel + * @param messageId The id of the base message (will not be included in results) + * @param number The maximum number of messages to get + * @param respond function(errStr, messages) as a socket.io response function + * @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, messageId, 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} m#${messageId} number: ${number}`); + const messages = await DB.getMessagesBefore(identity.guildId, channelId, messageId, number); + respond(null, messages); + } + ); - /* - * Fetch messages coming after the specified message - * @param channelId The channel uuid of the channel - * @param messageId The id of the base message (will not be included in results) - * @param number The maximum number of messages to get - * @param respond function(errStr, messages) as a socket.io response function - * @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, messageId, 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} m#${messageId} number: ${number}`); - const messages = await DB.getMessagesAfter(identity.guildId, channelId, messageId, number); - respond(null, messages); - } - ); + /* + * Fetch messages coming after the specified message + * @param channelId The channel uuid of the channel + * @param messageId The id of the base message (will not be included in results) + * @param number The maximum number of messages to get + * @param respond function(errStr, messages) as a socket.io response function + * @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, messageId, 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} m#${messageId} number: ${number}`); + const messages = await DB.getMessagesAfter(identity.guildId, channelId, messageId, number); + respond(null, messages); + } + ); - /* - * @param resourceId The id of the resource to fetch - * @param respond function(errStr, resource) as a socket.io response function - * @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); - } - ); + /* + * @param resourceId The id of the resource to fetch + * @param respond function(errStr, resource) as a socket.io response function + * @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); + } + ); - /* - * Fetch members in the guild - * @param respond function(errStr, members) as a socket.io response function - * @param respond.errStr Error string if an error, else null - * @param respond.members List of member data objects in the guild from PostgreSQL - */ - bindEvent( - client, identity, - { verified: true }, - 'fetch-members', [ 'function' ], - async (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 members`); - const members = await DB.getMembers(identity.guildId); - respond(null, members); - } - ); + /* + * Fetch members in the guild + * @param respond function(errStr, members) as a socket.io response function + * @param respond.errStr Error string if an error, else null + * @param respond.members List of member data objects in the guild from PostgreSQL + */ + bindEvent( + client, identity, + { verified: true }, + 'fetch-members', [ 'function' ], + async (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 members`); + const members = await DB.getMembers(identity.guildId); + respond(null, members); + } + ); } export function bindSocketEvents(io: socketio.Server): void { - io.on('connection', client => { - LOG.info(`c#${client.id}: connected`); + io.on('connection', client => { + LOG.info(`c#${client.id}: connected`); - const identity: IIdentity = { - guildId: null, - memberId: null, - verified: false, - publicKey: null, - challenge: null - }; - connected.push(identity); + const identity: IIdentity = { + guildId: null, + memberId: null, + verified: false, + publicKey: null, + challenge: null + }; + connected.push(identity); - //bindIdentificationEvents(io, keypair, client); - bindRegistrationEvents(io, client); - bindChallengeVerificationEvents(io, client, identity); - bindAdminEvents(io, client, identity); - bindActionEvents(io, client, identity); - bindFetchEvents(client, identity); + //bindIdentificationEvents(io, keypair, client); + bindRegistrationEvents(io, client); + bindChallengeVerificationEvents(io, client, identity); + bindAdminEvents(io, client, identity); + bindActionEvents(io, client, identity); + bindFetchEvents(client, identity); - client.on('disconnect', () => { - connected.splice(connected.findIndex(i => i.memberId == identity.memberId), 1); - if (identity.verified) { - LOG.info(`c#${client.id}: disconnected (was u#${identity.memberId})`); - (async () => { - if (!identity.guildId || !identity.memberId) return; - try { - await DB.setMemberStatus(identity.guildId, identity.memberId, 'offline'); - } catch (e) { - LOG.error('Error updating member status on disconnect', e); - } - })(); - } else { - LOG.info(`c#${client.id}: disconnected (was unverified)`); - } - }); - }); + client.on('disconnect', () => { + connected.splice(connected.findIndex(i => i.memberId == identity.memberId), 1); + if (identity.verified) { + LOG.info(`c#${client.id}: disconnected (was u#${identity.memberId})`); + (async () => { + if (!identity.guildId || !identity.memberId) return; + try { + await DB.setMemberStatus(identity.guildId, identity.memberId, 'offline'); + } catch (e) { + LOG.error('Error updating member status on disconnect', e); + } + })(); + } else { + LOG.info(`c#${client.id}: disconnected (was unverified)`); + } + }); + }); } diff --git a/src/server/sql/init.sql b/src/server/sql/init.sql index 08fa6c2..25834fc 100644 --- a/src/server/sql/init.sql +++ b/src/server/sql/init.sql @@ -77,7 +77,7 @@ $$ LANGUAGE plpgsql; CREATE TABLE "tokens" ( "id" UUID NOT NULL DEFAULT uuid_generate_v4() , "guild_id" UUID NOT NULL REFERENCES "guilds"("id") - , "expires" TIMESTAMP WITH TIME ZONE NOT NULL + , "expires" TIMESTAMP WITH TIME ZONE -- Null means never expires , "token" UUID NOT NULL DEFAULT uuid_generate_v4() , "member_id" UUID REFERENCES "members"("id") , "created" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()